pep-mklive/pylibraries/ttkbootstrap/tableview.py

2658 lines
90 KiB
Python
Raw Permalink Normal View History

2024-10-27 15:16:46 -01:00
import tkinter as tk
import ttkbootstrap as ttk
from ttkbootstrap.constants import *
from math import ceil
from datetime import datetime
from tkinter import font
from ttkbootstrap import utility
from typing import Any, Dict, List, Union
from ttkbootstrap.localization import MessageCatalog
UPARROW = ""
DOWNARROW = ""
ASCENDING = 0
DESCENDING = 1
class TableColumn:
"""Represents a column in a Tableview object"""
def __init__(
self,
tableview,
cid,
text,
image="",
command="",
anchor=W,
width=200,
minwidth=20,
stretch=False,
):
"""
Parameters:
tableview (Tableview):
The parent tableview object.
cid (str):
The column id.
text (str):
The header text.
image (PhotoImage):
An image that is displayed to the left of the header text.
command (Callable):
A function called whenever the header button is clicked.
anchor (str):
The position of the header text within the header. One
of "e", "w", "center".
width (int):
Specifies the width of the column in pixels.
minwidth (int):
Specifies the minimum width of the column in pixels.
stretch (bool):
Specifies whether or not the column width should be
adjusted whenever the widget is resized or the user
drags the column separator.
"""
self._table = tableview
self._cid = cid
self._headertext = text
self._sort = ASCENDING
self._settings_column = {}
self._settings_heading = {}
self.view: ttk.Treeview = tableview.view
self.view.column(
self._cid,
width=width,
minwidth=minwidth,
stretch=stretch,
anchor=anchor,
)
self.view.heading(
self._cid,
text=text,
anchor=anchor,
image=image,
command=command,
)
self._capture_settings()
self._table._cidmap[self._cid] = self
@property
def headertext(self):
"""The text on the header label"""
return self._headertext
@property
def columnsort(self):
"""Indicates how the column is to be sorted when the sorting
method is invoked."""
return self._sort
@columnsort.setter
def columnsort(self, value):
self._sort = value
@property
def cid(self):
"""A unique column identifier"""
return str(self._cid)
@property
def tableindex(self):
"""The index of the column as it is in the table configuration."""
cols = self.view.cget("columns")
if cols is None:
return
try:
return cols.index(self.cid)
except IndexError:
return
@property
def displayindex(self):
"""The index of the column as it is displayed"""
cols = self.view.cget("displaycolumns")
if "#all" in cols:
return self.tableindex
else:
return cols.index(self.cid)
def configure(self, opt=None, **kwargs):
"""Configure the column. If opt is provided, the
current value is returned, otherwise, sets the widget
options specified in kwargs. See the documentation for
`Tableview.insert_column` for configurable options.
Parameters:
opt (str):
A configuration option to query.
**kwargs (Dict):
Optional keyword arguments used to configure the
column and headers.
"""
# return queried options
if opt is not None:
if opt in ("anchor", "width", "minwidth", "stretch"):
return self.view.column(self.cid, opt)
elif opt in ("command", "text", "image"):
return self.view.heading(self.cid, opt)
else:
return
# configure column and heading
for k, v in kwargs.items():
if k in ("anchor", "width", "minwidth", "stretch"):
self._settings_column[k] = v
elif k in ("command", "text", "image"):
self._settings_heading[k] = v
self.view.column(self._cid, **self._settings_column)
self.view.heading(self._cid, **self._settings_heading)
if "text" in kwargs:
self._headertext = kwargs["text"]
def show(self):
"""Make the column visible in the tableview"""
displaycols = list(self.view.cget("displaycolumns"))
if "#all" in displaycols:
return
if self.cid in displaycols:
return
columns = list(self.view.cget("columns"))
index = columns.index(self.cid)
displaycols.insert(index, self.cid)
self.view.configure(displaycolumns=displaycols)
def hide(self):
"""Hide the column in the tableview"""
displaycols = list(self.view.cget("displaycolumns"))
cols = list(self.view.cget("columns"))
if "#all" in displaycols:
displaycols = cols
displaycols.remove(self.cid)
self.view.configure(displaycolumns=displaycols)
def delete(self):
"""Remove the column from the tableview permanently."""
# update the tablerow columns
index = self.tableindex
if index is None:
return
for row in self._table.tablerows:
row.values.pop(index)
row.refresh()
# actual columns
cols = list(self.view.cget("columns"))
cols.remove(self.cid)
self._table.tablecolumns.remove(self)
# visible columns
dcols = list(self.view.cget("displaycolumns"))
if "#all" in dcols:
dcols = cols
else:
dcols.remove(self.cid)
# remove cid mapping
self._table.cidmap.pop(self._cid)
# reconfigure the tableview column and displaycolumns
self.view.configure(columns=cols, displaycolumns=dcols)
# remove the internal object references
for i, column in enumerate(self._table.tablecolumns):
if column.cid == self.cid:
self._table.tablecolumns.pop(i)
else:
column.restore_settings()
def restore_settings(self):
"""Update the configuration based on stored settings"""
self.view.column(self.cid, **self._settings_column)
self.view.heading(self.cid, **self._settings_heading)
def _capture_settings(self):
"""Update the stored settings for the column and heading.
This is required because the settings are erased whenever
the `columns` parameter is configured in the underlying
Treeview widget."""
self._settings_heading = self.view.heading(self.cid)
self._settings_heading.pop("state")
self._settings_column = self.view.column(self.cid)
self._settings_column.pop("id")
class TableRow:
"""Represents a row in a Tableview object"""
_cnt = 0
def __init__(self, tableview, values):
"""
Parameters:
tableview (Tableview):
The Tableview widget that contains this row
values (List[Any, ...]):
A list of values to display in the row
"""
self.view: ttk.Treeview = tableview.view
self._values = list(values)
self._iid = None
self._sort = TableRow._cnt + 1
self._table = tableview
# increment cnt
TableRow._cnt += 1
@property
def values(self):
"""The table row values"""
return self._values
@values.setter
def values(self, values):
self._values = values
self.refresh()
@property
def iid(self):
"""A unique record identifier"""
return str(self._iid)
def configure(self, opt=None, **kwargs):
"""Configure the row. If opt is provided, the
current value is returned, otherwise, sets the widget
options specified in kwargs. See the documentation for
`Tableview.insert_row` for configurable options.
Parameters:
opt (str):
A configuration option to query.
**kwargs { values, tags }:
Optional keyword arguments used to configure the
row.
"""
if self._iid is None:
self.build()
if opt is not None:
return self.view.item(self.iid, opt)
elif 'values' in kwargs:
values = kwargs.pop('values')
self.values = values
else:
self.view.item(self.iid, **kwargs)
def show(self, striped=False):
"""Show the row in the data table view"""
if self._iid is None:
self.build()
self.view.reattach(self.iid, "", END)
# remove existing stripes
tags = list(self.view.item(self.iid, "tags"))
try:
tags.remove("striped")
except ValueError:
pass
# add stripes (if needed)
if striped:
tags.append("striped")
self.view.item(self.iid, tags=tags)
def delete(self):
"""Delete the row from the dataset"""
if self.iid:
self._table.iidmap.pop(self.iid)
self._table.tablerows_visible.remove(self)
self._table._tablerows.remove(self)
self._table.load_table_data()
self.view.delete(self.iid)
def hide(self):
"""Remove the row from the data table view"""
self.view.detach(self.iid)
def refresh(self):
"""Syncs the tableview values with the object values"""
if self._iid:
self.view.item(self.iid, values=self.values)
def build(self):
"""Create the row object in the `Treeview` and capture
the resulting item id (iid).
"""
if self._iid is None:
self._iid = self.view.insert("", END, values=self.values)
self._table.iidmap[self.iid] = self
class TableEvent:
"""A container class for holding table event objects"""
def __init__(self, column: TableColumn, row: TableRow):
self.column = column
self.row = row
class Tableview(ttk.Frame):
"""A class built on the `ttk.Treeview` widget for arranging data in
rows and columns. The underlying Treeview object and its methods are
exposed in the `Tableview.view` property.
A Tableview object contains various features such has striped rows,
pagination, and autosized and autoaligned columns.
The pagination option is recommended when loading a lot of data as
the table records are inserted on-demand. Table records are only
created when requested to be in a page view. This allows the table
to be loaded very quickly even with hundreds of thousands of
records.
All table columns are sortable. Clicking a column header will toggle
between sorting "ascending" and "descending".
Columns are configurable by passing a simple list of header names or
by passing in a dictionary of column names with settings. You can
use both as well, as in the example below, where a column header
name is use for one column, and a dictionary of settings is used
for another.
The object has a right-click menu on the header and the cells that
allow you to configure various settings.
![](../../assets/widgets/tableview-1.png)
![](../../assets/widgets/tableview-2.png)
Examples:
Adding data with the constructor
```python
import ttkbootstrap as ttk
from ttkbootstrap.tableview import Tableview
from ttkbootstrap.constants import *
app = ttk.Window()
colors = app.style.colors
coldata = [
{"text": "LicenseNumber", "stretch": False},
"CompanyName",
{"text": "UserCount", "stretch": False},
]
rowdata = [
('A123', 'IzzyCo', 12),
('A136', 'Kimdee Inc.', 45),
('A158', 'Farmadding Co.', 36)
]
dt = Tableview(
master=app,
coldata=coldata,
rowdata=rowdata,
paginated=True,
searchable=True,
bootstyle=PRIMARY,
stripecolor=(colors.light, None),
)
dt.pack(fill=BOTH, expand=YES, padx=10, pady=10)
app.mainloop()
```
Add data with methods
```python
dt.insert_row('end', ['Marzale LLC', 26])
```
"""
def __init__(
self,
master=None,
bootstyle=DEFAULT,
coldata=[],
rowdata=[],
paginated=False,
searchable=False,
autofit=False,
autoalign=True,
stripecolor=None,
pagesize=10,
height=10,
delimiter=",",
):
"""
Parameters:
master (Widget):
The parent widget.
bootstyle (str):
A style keyword used to set the focus color of the entry
and the background color of the date button. Available
options include -> primary, secondary, success, info,
warning, danger, dark, light.
coldata (List[str | Dict]):
An iterable containing either the heading name or a
dictionary of column settings. Configurable settings
include >> text, image, command, anchor, width, minwidth,
maxwidth, stretch. Also see `Tableview.insert_column`.
rowdata (List):
An iterable of row data. The lenth of each row of data
must match the number of columns. Also see
`Tableview.insert_row`.
paginated (bool):
Specifies that the data is to be paginated. A pagination
frame will be created below the table with controls that
enable the user to page forward and backwards in the
data set.
pagesize (int):
When `paginated=True`, this specifies the number of rows
to show per page.
searchable (bool):
If `True`, a searchbar will be created above the table.
Press the <Return> key to initiate a search. Searching
with an empty string will reset the search criteria, or
pressing the reset button to the right of the search
bar. Currently, the search method looks for any row
that contains the search text. The filtered results
are displayed in the table view.
autofit (bool):
If `True`, the table columns will be automatically sized
when loaded based on the records in the current view.
Also see `Tableview.autofit_columns`.
autoalign (bool):
If `True`, the column headers and data are automatically
aligned. Numbers and number headers are right-aligned
and all other data types are left-aligned. The auto
align method evaluates the first record in each column
to determine the data type for alignment. Also see
`Tableview.autoalign_columns`.
stripecolor (Tuple[str, str]):
If provided, even numbered rows will be color using the
(background, foreground) specified. You may specify one
or the other by passing in **None**. For example,
`stripecolor=('green', None)` will set the stripe
background as green, but the foreground will remain as
default. You may use standand color names, hexadecimal
color codes, or bootstyle color keywords. For example,
('light', '#222') will set the background to the "light"
themed ttkbootstrap color and the foreground to the
specified hexadecimal color. Also see
`Tableview.apply_table_stripes`.
height (int):
Specifies how many rows will appear in the table's viewport.
If the number of records extends beyond the table height,
the user may use the mousewheel or scrollbar to navigate
the data.
delimiter (str):
The character to use as a delimiter when exporting data
to CSV.
"""
super().__init__(master)
self._tablecols = []
self._tablerows = []
self._tablerows_filtered = []
self._viewdata = []
self._rowindex = tk.IntVar(value=0)
self._pageindex = tk.IntVar(value=1)
self._pagelimit = tk.IntVar(value=0)
self._height = height
self._pagesize = tk.IntVar(value=pagesize)
self._paginated = paginated
self._searchable = searchable
self._stripecolor = stripecolor
self._autofit = autofit
self._autoalign = autoalign
self._filtered = False
self._sorted = False
self._searchcriteria = tk.StringVar()
self._rightclickmenu_cell = None
self._delimiter = delimiter
self._iidmap = {} # maps iid to row object
self._cidmap = {} # maps cid to col object
self.view: ttk.Treeview = None
self._build_tableview_widget(coldata, rowdata, bootstyle)
@property
def tablerows(self):
"""A list of all tablerow objects"""
return self._tablerows
@property
def tablerows_filtered(self):
"""A list of filtered tablerow objects"""
return self._tablerows_filtered
@property
def tablerows_visible(self):
"""A list of visible tablerow objects"""
return self._viewdata
@property
def tablecolumns(self):
"""A list of table column objects"""
return self._tablecols
@property
def tablecolumns_visible(self):
"""A list of visible table column objects"""
cids = list(self.view.cget("displaycolumns"))
if "#all" in cids:
return self._tablecols
columns = []
for cid in cids:
# the cidmap expects an integer
columns.append(self.cidmap.get(int(cid)))
return columns
@property
def is_filtered(self):
"""Indicates whether the table is currently filtered"""
return self._filtered
@property
def searchcriteria(self):
"""The criteria used to filter the records when the search
method is invoked"""
return self._searchcriteria.get()
@searchcriteria.setter
def searchcriteria(self, value):
self._searchcriteria.set(value)
@property
def pagesize(self):
"""The number of records visible on a single page"""
return self._pagesize.get()
@pagesize.setter
def pagesize(self, value):
self._pagesize.set(value)
@property
def iidmap(self) -> Dict[str, TableRow]:
"""A map of iid to tablerow object"""
return self._iidmap
@property
def cidmap(self) -> Dict[str, TableColumn]:
"""A map of cid to tablecolumn object"""
return self._cidmap
def configure(self, cnf=None, **kwargs) -> Union[Any, None]:
"""Configure the internal `Treeview` widget. If cnf is provided,
value of the option is return. Otherwise the widget is
configured via kwargs.
Parameters:
cnf (Any):
An option to query.
**kwargs (Dict):
Optional keyword arguments used to configure the internal
Treeview widget.
Returns:
Union[Any, None]:
The value of cnf or None.
"""
try:
if "pagesize" in kwargs:
pagesize: int = kwargs.pop("pagesize")
self._pagesize.set(value=pagesize)
self.view.configure(cnf, **kwargs)
except:
super().configure(cnf, **kwargs)
# DATA HANDLING
def build_table_data(self, coldata, rowdata):
"""Insert the specified column and row data.
The coldata can be either a string column name or a dictionary
of column settings that are passed to the `insert_column`
method. You may use a mixture of string and dictionary in
the list of coldata.
!!!warning "Existing table data will be erased.
This method will completely rebuild the underlying table
with the new column and row data. Any existing data will
be lost.
Parameters:
coldata (List[Union[str, Dict]]):
An iterable of column names and/or settings.
rowdata (List):
An iterable of row values.
"""
# destroy the existing data if existing
self.purge_table_data()
# build the table columns
for i, col in enumerate(coldata):
if isinstance(col, str):
# just a column name
self.insert_column(i, col)
else:
# a dictionary of column settings
self.insert_column(i, **col)
# build the table rows
for values in rowdata:
self.insert_row(values=values)
# load the table data
self.load_table_data()
# apply table formatting
if self._autofit:
self.autofit_columns()
if self._autoalign:
self.autoalign_columns()
if self._stripecolor is not None:
self.apply_table_stripes(self._stripecolor)
self.goto_first_page()
def insert_row(self, index=END, values=[]) -> TableRow:
"""Insert a row into the tableview at index.
You must call `Tableview.load_table_data()` to update the
current view. If the data is filtered, you will need to call
`Tableview.load_table_data(clear_filters=True)`.
Parameters:
index (Union[int, str]):
A numerical index that specifieds where to insert
the record in the dataset. You may also use the string
'end' to append the record to the end of the data set.
If the index exceeds the record count, it will be
appended to the end of the dataset.
values (Iterable):
An iterable of values to insert into the data set.
The number of columns implied by the list of values
must match the number of columns in the data set for
the values to be visible.
Returns:
TableRow:
A table row object.
"""
rowcount = len(self._tablerows)
# validate the index
if len(values) == 0:
return
if index == END:
index = -1
elif index > rowcount - 1:
index = -1
record = TableRow(self, values)
if rowcount == 0 or index == -1:
self._tablerows.append(record)
else:
self._tablerows.insert(index, record)
return record
def insert_rows(self, index, rowdata):
"""Insert row after index for each row in *row. If index does
not exist then the records are appended to the end of the table.
You can also use the string 'end' to append records at the end
of the table.
Parameters:
index (Union[int, str]):
The location in the data set after where the records
will be inserted. You may use a numerical index or
the string 'end', which will append the records to the
end of the data set.
rowdata (List[Any, List]):
A list of row values to be inserted into the table.
Examples:
```python
Tableview.insert_rows('end', ['one', 1], ['two', 2])
```
"""
if len(rowdata) == 0:
return
for values in reversed(rowdata):
self.insert_row(index, values)
def delete_column(self, index=None, cid=None, visible=True):
"""Delete the specified column based on the column index or the
unique cid.
Unless otherwise specified, the index refers to the column index
as displayed in the tableview.
If cid is provided, the column associated with the cid is deleted
regardless of whether it is in the visible data sets.
Parameters:
index (int):
The numerical index of the column.
cid (str):
A unique column indentifier.
visible (bool):
Specifies that the index should refer to the visible
columns. Otherwise, if False, the original column
position is used.
"""
if cid is not None:
column: TableColumn = self.cidmap(int(cid))
column.delete()
elif index is not None and visible:
self.tablecolumns_visible[int(index)].delete()
elif index is None and not visible:
self.tablecolumns[int(index)].delete()
def delete_columns(self, indices=None, cids=None, visible=True):
"""Delete columns specified by indices or cids.
Unless specified otherwise, the index refers to the position
of the columns in the table from left to right starting with
index 0.
!!!Warning "Use this method with caution!
This method may or may not suffer performance issues.
Internally, this method calls the `delete_column` method
on each column specified in the list. The `delete_column`
method deletes the related column from each record in
the table data. So, if there are a lot of records this
could be problematic. It may be more beneficial to use
the `build_table_data` if you plan on changing the
structure of the table dramatically.
Parameters:
indices (List[int]):
A list of column indices to delete from the table.
cids (List[str]):
A list of unique column identifiers to delete from the
table.
visible (bool):
If True, the index refers to the visible position of the
column in the stable, from left to right starting at
index 0.
"""
if cids is not None:
for cid in cids:
self.delete_column(cid=cid)
elif indices is not None:
for index in indices:
self.delete_column(index=index, visible=visible)
def delete_row(self, index=None, iid=None, visible=True):
"""Delete a record from the data set.
Unless specified otherwise, the index refers to the record
position within the visible data set from top to bottom
starting with index 0.
If iid is provided, the record associated with the cid is deleted
regardless of whether it is in the visible data set.
Parameters:
index (int):
The numerical index of the record within the data set.
iid (str):
A unique record identifier.
visible (bool):
Indicates that the record index is relative to the current
records in view, otherwise, the original data set index is
used if False.
"""
# delete from iid
if iid is not None:
record: TableRow = self.iidmap.get(iid)
record.delete()
elif index is not None:
# visible index
if visible:
record = self.tablerows_visible[index]
record.delete()
# original index
else:
for record in self.tablerows:
if record._sort == index:
record.delete()
def delete_rows(self, indices=None, iids=None, visible=True):
"""Delete rows specified by indices or iids.
If both indices and iids are None, then all records in the
table will be deleted.
"""
# remove records by iid
if iids is not None:
for iid in iids:
self.delete_row(iid=iid)
# remove records by index
elif indices is not None:
for index in indices:
self.delete_row(index=index, visible=visible)
# remove ALL records
else:
self._tablerows.clear()
self._tablerows_filtered.clear()
self._viewdata.clear()
self._iidmap.clear()
records = self.view.get_children()
self.view.delete(*records)
# route to new page if no records visible
if len(self._viewdata) == 0:
self.goto_page()
def insert_column(
self,
index,
text="",
image="",
command="",
anchor=W,
width=200,
minwidth=20,
stretch=False,
) -> TableColumn:
"""
Parameters:
index (Union[int, str]):
A numerical index that specifieds where to insert
the column. You may also use the string 'end' to
insert the column in the right-most position. If the
index exceeds the column count, it will be inserted
at the right-most position.
text (str):
The header text.
image (PhotoImage):
An image that is displayed to the left of the header text.
command (Callable):
A function called whenever the header button is clicked.
anchor (str):
The position of the header text within the header. One
of "e", "w", "center".
width (int):
Specifies the width of the column in pixels.
minwidth (int):
Specifies the minimum width of the column in pixels.
stretch (bool):
Specifies whether or not the column width should be
adjusted whenever the widget is resized or the user
drags the column separator.
Returns:
TableColumn:
A table column object.
"""
self.reset_table()
colcount = len(self.tablecolumns)
cid = colcount
if index == END:
index = -1
elif index > colcount - 1:
index = -1
# actual columns
cols = self.view.cget("columns")
if len(cols) > 0:
cols = [int(x) for x in cols]
cols.append(cid)
else:
cols = [cid]
# visible columns
dcols = self.view.cget("displaycolumns")
if "#all" in dcols:
dcols = cols
elif len(dcols) > 0:
dcols = [int(x) for x in dcols]
if index == -1:
dcols.append(cid)
else:
dcols.insert(index, cid)
else:
dcols = [cid]
self.view.configure(columns=cols, displaycolumns=dcols)
# configure new column
column = TableColumn(
tableview=self,
cid=cid,
text=text,
image=image,
command=command,
anchor=anchor,
width=width,
minwidth=minwidth,
stretch=stretch,
)
self._tablecols.append(column)
# must be called to show the header after initially creating it
# ad hoc, not sure why this should be the case;
self._column_sort_header_reset()
# update settings after they are erased when a column is
# inserted
for column in self._tablecols:
column.restore_settings()
return column
def purge_table_data(self):
"""Erase all table and column data.
This method will completely destroy the table data structure.
The table will need to be completely rebuilt after using this
method.
"""
self.delete_rows()
self.cidmap.clear()
self.tablecolumns.clear()
self.view.configure(columns=[], displaycolumns=[])
def unload_table_data(self):
"""Unload all data from the table"""
for row in self.tablerows_visible:
row.hide()
self.tablerows_visible.clear()
def load_table_data(self, clear_filters=False):
"""Load records into the tableview.
Parameters:
clear_filters (bool):
Specifies that the table filters should be cleared
before loading the data into the view.
"""
if len(self.tablerows) == 0:
return
if clear_filters:
self.reset_table()
self.unload_table_data()
if self._paginated:
page_start = self._rowindex.get()
page_end = self._rowindex.get() + self._pagesize.get()
else:
page_start = 0
page_end = len(self._tablerows)
if self._filtered:
rowdata = self._tablerows_filtered[page_start:page_end]
rowcount = len(self._tablerows_filtered)
else:
rowdata = self._tablerows[page_start:page_end]
rowcount = len(self._tablerows)
self._pagelimit.set(ceil(rowcount / self._pagesize.get()))
pageindex = ceil(page_end / self._pagesize.get())
pagelimit = self._pagelimit.get()
self._pageindex.set(min([pagelimit, pageindex]))
for i, row in enumerate(rowdata):
if self._stripecolor is not None and i % 2 == 0:
row.show(True)
else:
row.show(False)
self._viewdata.append(row)
def fill_empty_columns(self, fillvalue=""):
"""Fill empty columns with the fillvalue.
This method can be used to fill in missing values when a column
column is inserted after data has already been inserted into
the tableview.
Parameters:
fillvalue (Any):
A value to insert into an empty column
"""
rowcount = len(self._tablerows)
if rowcount == 0:
return
colcount = len(self._tablecols)
for row in self._tablerows:
var = colcount - len(row._values)
if var <= 0:
return
else:
for _ in range(var):
row._values.append(fillvalue)
row.configure(values=row._values)
# CONFIGURATION
def get_columns(self) -> List[TableColumn]:
"""Returns a list of all column objects. Same as using the
`Tableview.tablecolumns` property."""
return self._tablecols
def get_column(
self, index=None, visible=False, cid=None
) -> TableColumn:
"""Returns the `TableColumn` object from an index or a cid.
If index is specified, the column index refers to the index
within the original, unless the visible flag is set, in which
case the index is relative to the visible columns in view.
If cid is specified, the column associated with the cid is
return regardless of whether it is visible.
Parameters:
index (int):
The numerical index of the column.
visible (bool):
Use the index of the visible columns as they appear
in the table.
Returns:
Union[TableColumn, None]:
The table column object if found, otherwise None.
"""
if cid is not None:
return self._cidmap.get(cid)
if not visible:
# original column index
try:
return self._tablecols[index]
except IndexError:
return None
else:
# visible column index
cols = self.view.cget("columns")
if len(cols) > 0:
cols = [int(x) for x in cols]
else:
cols = []
dcols = self.view.cget("displaycolumns")
if "#all" in dcols:
dcols = cols
else:
try:
x = int(dcols[index])
for c in self._tablecols:
if c.cid == x:
return c
except ValueError:
return None
def get_rows(self, visible=False, filtered=False, selected=False) -> List[TableRow]:
"""Return a list of TableRow objects.
Return a subset of rows based on optional flags. Only ONE flag can be used
at a time. If more than one flag is set to `True`, then the first flag will
be used to return the data.
Parameters:
visible (bool):
If true, only records in the current view will be returned.
filtered (bool):
If True, only rows in the filtered dataset will be returned.
selected (bool):
If True, only rows that are currently selected will be returned.
Returns:
List[TableRow]:
A list of TableRow objects.
"""
if visible:
return self._viewdata
elif filtered:
return self._tablerows_filtered
elif selected:
return [row for row in self._viewdata if row.iid in self.view.selection()]
else:
return self._tablerows
def get_row(self, index=None, visible=False, filtered=False, iid=None) -> TableRow:
"""Returns the `TableRow` object from an index or the iid.
If an index is specified, the row index refers to the index
within the original dataset. When choosing a subset of data,
the visible data takes priority over filtered if both flags
are set.
If an iid is specified, the object attached to that iid is
returned regardless of whether or not it is visible or
filtered.
Parameters:
index (int):
The numerical index of the column.
iid (str):
A unique column identifier.
visible (bool):
Use the index of the visible rows as they appear
in the current table view.
filtered (bool):
Use the index of the rows within the filtered data
set.
Returns:
Union[TableRow, None]:
The table column object if found, otherwise None
"""
if iid is not None:
return self.iidmap.get(iid)
if visible:
try:
return self.tablerows_visible[index]
except IndexError:
return None
elif filtered:
try:
return self.tablerows_filtered[index]
except IndexError:
return None
else:
try:
return self.tablerows[index]
except IndexError:
return None
# PAGE NAVIGATION
def _select_first_visible_item(self):
try:
iid = self.tablerows_visible[0].iid
self.view.selection_set(iid)
# must force focus, sometimes just focus on iid doesn't work
self.view.focus_force()
# this sets the focus on the specific row item
self.view.focus(iid)
# make sure the row is visible
self.view.see(iid)
except:
pass
def goto_first_page(self):
"""Update table with first page of data"""
self._rowindex.set(0)
self.load_table_data()
self._select_first_visible_item()
def goto_last_page(self):
"""Update table with the last page of data"""
pagelimit = self._pagelimit.get() - 1
self._rowindex.set(self.pagesize * pagelimit)
self.load_table_data()
self._select_first_visible_item()
def goto_next_page(self):
"""Update table with next page of data"""
if self._pageindex.get() >= self._pagelimit.get():
return
rowindex = self._rowindex.get()
self._rowindex.set(rowindex + self.pagesize)
self.load_table_data()
self._select_first_visible_item()
def goto_prev_page(self):
"""Update table with prev page of data"""
if self._pageindex.get() <= 1:
return
rowindex = self._rowindex.get()
self._rowindex.set(rowindex - self.pagesize)
self.load_table_data()
self._select_first_visible_item()
def goto_page(self, *_):
"""Go to a specific page indicated by the page entry widget."""
pagelimit = self._pagelimit.get()
pageindex = self._pageindex.get()
if pageindex > pagelimit:
pageindex = pagelimit
self._pageindex.set(pageindex)
elif pageindex <= 0:
pageindex = 1
self._pageindex.set(pageindex)
rowindex = (pageindex * self.pagesize) - self.pagesize
self._rowindex.set(rowindex)
self.load_table_data()
self._select_first_visible_item()
# COLUMN SORTING
def sort_column_data(self, event=None, cid=None, sort=None):
"""Sort the table rows by the specified column. This method
may be trigged by an event or manually.
Parameters:
event (Event):
A window event.
cid (int):
A unique column identifier; typically the numerical
index of the column relative to the original data set.
sort (int):
Determines the sort direction. 0 = ASCENDING. 1 = DESCENDING.
"""
if event is not None:
eo = self._get_event_objects(event)
column = eo.column
index = column.tableindex
elif cid is not None:
column: TableColumn = self.cidmap.get(int(cid))
index = column.tableindex
else:
return
# update table data
if self.is_filtered:
tablerows = self.tablerows_filtered
else:
tablerows = self.tablerows
if sort is not None:
columnsort = sort
else:
columnsort = self.tablecolumns[index].columnsort
if columnsort == ASCENDING:
self._tablecols[index].columnsort = DESCENDING
else:
self._tablecols[index].columnsort = ASCENDING
try:
sortedrows = sorted(
tablerows, reverse=columnsort, key=lambda x: x.values[index]
)
except:
# when data is missing, or sometimes with numbers
# this is still not right, but it works most of the time
# fix sometime down the road when I have time
self.fill_empty_columns()
sortedrows = sorted(
tablerows, reverse=columnsort, key=lambda x: int(x.values[index])
)
if self.is_filtered:
self._tablerows_filtered = sortedrows
else:
self._tablerows = sortedrows
# update headers
self._column_sort_header_reset()
self._column_sort_header_update(column.cid)
self.unload_table_data()
self.load_table_data()
self._select_first_visible_item()
# DATA SEARCH & FILTERING
def reset_row_filters(self):
"""Remove all row level filters; unhide all rows."""
self._filtered = False
self.searchcriteria = ""
self.unload_table_data()
self.load_table_data()
def reset_column_filters(self):
"""Remove all column level filters; unhide all columns."""
cols = [col.cid for col in self.tablecolumns]
self.view.configure(displaycolumns=cols)
def reset_row_sort(self):
"""Display all table rows by original insert index"""
...
def reset_column_sort(self):
"""Display all columns by original insert index"""
cols = sorted([col.cid for col in self.tablecolumns_visible], key=int)
self.view.configure(displaycolumns=cols)
def reset_table(self):
"""Remove all table data filters and column sorts"""
self._filtered = False
self.searchcriteria = ""
try:
sortedrows = sorted(self.tablerows, key=lambda x: x._sort)
except IndexError:
self.fill_empty_columns()
sortedrows = sorted(self.tablerows, key=lambda x: x._sort)
self._tablerows = sortedrows
self.unload_table_data()
# reset the columns
self.reset_column_filters()
self.reset_column_sort()
self._column_sort_header_reset()
self.goto_first_page() # needed?
def filter_column_to_value(self, event=None, cid=None, value=None):
"""Hide all records except for records where the current
column exactly matches the provided value. This method may
be triggered by a window event or by specifying the column id.
Parameters:
event (Event):
A window click event.
cid (int):
A unique column identifier; typically the numerical
index of the column within the original dataset.
value (Any):
The criteria used to filter the column.
"""
if event is not None:
eo = self._get_event_objects(event)
index = eo.column.tableindex
value = value or eo.row.values[index]
elif cid is not None:
column: TableColumn = self.cidmap.get(cid)
index = column.tableindex
else:
return
self._filtered = True
self.tablerows_filtered.clear()
self.unload_table_data()
for row in self.tablerows:
if row.values[index] == value:
self.tablerows_filtered.append(row)
self._rowindex.set(0)
self.load_table_data()
def filter_to_selected_rows(self):
"""Hide all records except for the selected rows"""
criteria = self.view.selection()
if len(criteria) == 0:
return # nothing is selected
if self.is_filtered:
for row in self.tablerows_visible:
if row.iid not in criteria:
row.hide()
self.tablerows_filtered.remove(row)
else:
self._filtered = True
self.tablerows_filtered.clear()
for row in self.tablerows_visible:
if row.iid in criteria:
self.tablerows_filtered.append(row)
self._rowindex.set(0)
self.load_table_data()
def hide_selected_rows(self):
"""Hide the currently selected rows"""
selected = self.view.selection()
view_cnt = len(self._viewdata)
hide_cnt = len(selected)
self.view.detach(*selected)
tablerows = []
for row in self.tablerows_visible:
if row.iid in selected:
tablerows.append(row)
if not self.is_filtered:
self._filtered = True
self._tablerows_filtered = self.tablerows.copy()
for row in tablerows:
if self.is_filtered:
self.tablerows_filtered.remove(row)
if hide_cnt == view_cnt:
# assuming that if the count of the records on the page are
# selected for hiding, then need to go to the next page
# The call to `load_table_data` is duplicative, but currently
# this is the only way to get this to work until I've
# refactored this bit.
self.load_table_data()
self.goto_page()
else:
self.load_table_data()
def hide_selected_column(self, event=None, cid=None):
"""Detach the selected column from the tableview. This method
may be triggered by a window event or by specifying the column
id.
Parameters:
event (Event):
A window click event
cid (int):
A unique column identifier; typically the numerical
index of the column within the original dataset.
"""
if event is not None:
eo = self._get_event_objects(event)
column = eo.column.hide()
elif cid is not None:
column: TableColumn = self.cidmap.get(cid)
column.hide()
def unhide_selected_column(self, event=None, cid=None):
"""Attach the selected column to the tableview. This method
may be triggered by a window event or by specifying the column
id. The column is reinserted at the index in the original data
set.
Parameters:
event (Event):
An application click event
cid (int):
A unique column identifier; typically the numerical
index of the column within the original dataset.
"""
if event is not None:
eo = self._get_event_objects(event)
eo.column.show()
elif cid is not None:
column = self.cidmap.get(cid)
column.show()
# DATA EXPORT
def export_all_records(self):
"""Export all records to a csv file"""
headers = [col.headertext for col in self.tablecolumns]
records = [row.values for row in self.tablerows]
self.save_data_to_csv(headers, records, self._delimiter)
def export_current_page(self):
"""Export records on current page to csv file"""
headers = [col.headertext for col in self.tablecolumns]
records = [row.values for row in self.tablerows_visible]
self.save_data_to_csv(headers, records, self._delimiter)
def export_current_selection(self):
"""Export rows currently selected to csv file"""
headers = [col.headertext for col in self.tablecolumns]
selected = self.view.selection()
records = []
for iid in selected:
record: TableRow = self.iidmap.get(iid)
records.append(record.values)
self.save_data_to_csv(headers, records, self._delimiter)
def export_records_in_filter(self):
"""Export rows currently filtered to csv file"""
headers = [col.headertext for col in self.tablecolumns]
if not self.is_filtered:
return
records = [row.values for row in self.tablerows_filtered]
self.save_data_to_csv(headers, records, self._delimiter)
def save_data_to_csv(self, headers, records, delimiter=","):
"""Save data records to a csv file.
Parameters:
headers (List[str]):
A list of header labels.
records (List[Tuple[...]]):
A list of table records.
delimiter (str):
The character to use for delimiting the values.
"""
from tkinter.filedialog import asksaveasfilename
import csv
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
initialfile = f"tabledata_{timestamp}.csv"
filetypes = [
("CSV UTF-8 (Comma delimited)", "*.csv"),
("All file types", "*.*"),
]
filename = asksaveasfilename(
confirmoverwrite=True,
filetypes=filetypes,
defaultextension="csv",
initialfile=initialfile,
)
if filename:
with open(filename, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f, delimiter=delimiter)
writer.writerow(headers)
writer.writerows(records)
# ROW MOVEMENT
def move_selected_rows_to_top(self):
"""Move the selected rows to the top of the data set"""
selected = self.view.selection()
if len(selected) == 0:
return
if self.is_filtered:
tablerows = self.tablerows_filtered.copy()
else:
tablerows = self.tablerows.copy()
for i, iid in enumerate(selected):
row = self.iidmap.get(iid)
tablerows.remove(row)
tablerows.insert(i, row)
if self.is_filtered:
self._tablerows_filtered = tablerows
else:
self._tablerows = tablerows
# refresh the table data
self.unload_table_data()
self.load_table_data()
def move_selected_rows_to_bottom(self):
"""Move the selected rows to the bottom of the dataset"""
selected = self.view.selection()
if len(selected) == 0:
return
if self.is_filtered:
tablerows = self.tablerows_filtered.copy()
else:
tablerows = self.tablerows.copy()
for iid in selected:
row = self.iidmap.get(iid)
tablerows.remove(row)
tablerows.append(row)
if self.is_filtered:
self._tablerows_filtered = tablerows
else:
self._tablerows = tablerows
# refresh the table data
self.unload_table_data()
self.load_table_data()
def move_selected_row_up(self):
"""Move the selected rows up one position in the dataset"""
selected = self.view.selection()
if len(selected) == 0:
return
if self.is_filtered:
tablerows = self._tablerows_filtered.copy()
else:
tablerows = self.tablerows.copy()
for iid in selected:
row = self.iidmap.get(iid)
index = tablerows.index(row) - 1
tablerows.remove(row)
tablerows.insert(index, row)
if self.is_filtered:
self._tablerows_filtered = tablerows
else:
self._tablerows = tablerows
# refresh the table data
self.unload_table_data()
self.load_table_data()
def move_row_down(self):
"""Move the selected rows down one position in the dataset"""
selected = self.view.selection()
if len(selected) == 0:
return
if self._filtered:
tablerows = self._tablerows_filtered
else:
tablerows = self._tablerows
for iid in selected:
row = self.iidmap.get(iid)
index = tablerows.index(row) + 1
tablerows.remove(row)
tablerows.insert(index, row)
if self._filtered:
self._tablerows_filtered = tablerows
else:
self._tablerows = tablerows
# refresh the table data
self.unload_table_data()
self.load_table_data()
# COLUMN MOVEMENT
def move_column_left(self, event=None, cid=None):
"""Move column one position to the left. This can be triggered
by either an event, or by passing in the `cid`, which is the
index of the column relative to the original data set.
Parameters:
event (Event):
An application click event.
cid (int):
A unique column identifier; typically the index of the
column relative to the original dataset.
"""
if event is not None:
eo = self._get_event_objects(event)
column = eo.column
elif cid is not None:
column = self.cidmap.get(cid)
else:
return
displaycols = [x.cid for x in self.tablecolumns_visible]
old_index = column.displayindex
if old_index == 0:
return
new_index = column.displayindex - 1
displaycols.insert(new_index, displaycols.pop(old_index))
self.view.configure(displaycolumns=displaycols)
def move_column_right(self, event=None, cid=None):
"""Move column one position to the right. This can be triggered
by either an event, or by passing in the `cid`, which is the
index of the column relative to the original data set.
Parameters:
event (Event):
An application click event.
cid (int):
A unique column identifier; typically the index of the
column relative to the original dataset.
"""
if event is not None:
eo = self._get_event_objects(event)
column = eo.column
elif cid is not None:
column = self.cidmap.get(cid)
else:
return
displaycols = [x.cid for x in self.tablecolumns_visible]
old_index = column.displayindex
if old_index == len(displaycols) - 1:
return
new_index = old_index + 1
displaycols.insert(new_index, displaycols.pop(old_index))
self.view.configure(displaycolumns=displaycols)
def move_column_to_first(self, event=None, cid=None):
"""Move column to leftmost position. This can be triggered by
either an event, or by passing in the `cid`, which is the index
of the column relative to the original data set.
Parameters:
event (Event):
An application click event.
cid (int):
A unique column identifier; typically the index of the
column relative to the original dataset.
"""
if event is not None:
eo = self._get_event_objects(event)
column = eo.column
elif cid is not None:
column = self.cidmap.get(cid)
else:
return
displaycols = [x.cid for x in self.tablecolumns_visible]
old_index = column.displayindex
if old_index == 0:
return
displaycols.insert(0, displaycols.pop(old_index))
self.view.configure(displaycolumns=displaycols)
def move_column_to_last(self, event=None, cid=None):
"""Move column to the rightmost position. This can be triggered
by either an event, or by passing in the `cid`, which is the
index of the column relative to the original data set.
Parameters:
event (Event):
An application click event.
cid (int):
A unique column identifier; typically the index of the
column relative to the original dataset.
"""
if event is not None:
eo = self._get_event_objects(event)
column = eo.column
elif cid is not None:
column = self.cidmap.get(cid)
else:
return
displaycols = [x.cid for x in self.tablecolumns_visible]
old_index = column.displayindex
if old_index == len(displaycols) - 1:
return
new_index = len(displaycols) - 1
displaycols.insert(new_index, displaycols.pop(old_index))
self.view.configure(displaycolumns=displaycols)
# OTHER FORMATTING
def apply_table_stripes(self, stripecolor):
"""Add stripes to even-numbered table rows as indicated by the
`stripecolor` of (background, foreground). Either element may be
specified as `None`, but both elements must be present.
Parameters:
stripecolor (Tuple[str, str]):
A tuple of colors to apply to the table stripe. The
tuple represents (background, foreground).
"""
style: ttk.Style = ttk.Style.get_instance()
colors = style.colors
if len(stripecolor) == 2:
self._stripecolor = stripecolor
bg, fg = stripecolor
kw = {}
if bg is None:
kw["background"] = colors.active
else:
kw["background"] = bg
if fg is None:
kw["foreground"] = colors.inputfg
else:
kw["foreground"] = fg
self.view.tag_configure("striped", **kw)
def autofit_columns(self):
"""Autofit all columns in the current view"""
f = font.nametofont("TkDefaultFont")
pad = utility.scale_size(self, 20)
col_widths = []
# measure header sizes
for col in self.tablecolumns:
width = f.measure(f"{col._headertext} {DOWNARROW}") + pad
col_widths.append(width)
for row in self.tablerows_visible:
values = row.values
for i, value in enumerate(values):
old_width = col_widths[i]
new_width = f.measure(str(value)) + pad
width = max(old_width, new_width)
col_widths[i] = width
for i, width in enumerate(col_widths):
self.view.column(i, width=width)
# COLUMN AND HEADER ALIGNMENT
def autoalign_columns(self):
"""Align the columns and headers based on the data type of the
values. Text is left-aligned; numbers are right-aligned. This
method will have no effect if there is no data in the tables."""
if len(self._tablerows) == 0:
return
values = self._tablerows[0]._values
for i, value in enumerate(values):
if str(value).isnumeric():
self.view.column(i, anchor=E)
self.view.heading(i, anchor=E)
else:
self.view.column(i, anchor=W)
self.view.heading(i, anchor=W)
def align_column_left(self, event=None, cid=None):
"""Left align the column text. This can be triggered by
either an event, or by passing in the `cid`, which is the index
of the column relative to the original data set.
Parameters:
event (Event):
An application click event.
cid (int):
A unique column identifier; typically the index of the
column relative to the original dataset.
"""
if event is not None:
eo = self._get_event_objects(event)
self.view.column(eo.column.cid, anchor=W)
elif cid is not None:
self.view.column(cid, anchor=W)
def align_column_right(self, event=None, cid=None):
"""Right align the column text. This can be triggered by
either an event, or by passing in the `cid`, which is the index
of the column relative to the original data set.
Parameters:
event (Event):
An application event.
cid (int):
A unique column identifier; typically the index of the
column relative to the original dataset.
"""
if event is not None:
eo = self._get_event_objects(event)
self.view.column(eo.column.cid, anchor=E)
elif cid is not None:
self.view.column(cid, anchor=E)
def align_column_center(self, event=None, cid=None):
"""Center align the column text. This can be triggered by
either an event, or by passing in the `cid`, which is the index
of the column relative to the original data set.
Parameters:
event (Event):
An application event.
cid (int):
A unique column identifier; typically the index of the
column relative to the original dataset.
"""
if event is not None:
eo = self._get_event_objects(event)
self.view.column(eo.column.cid, anchor=CENTER)
elif cid is not None:
self.view.column(cid, anchor=CENTER)
def align_heading_left(self, event=None, cid=None):
"""Left align the heading text. This can be triggered by
either an event, or by passing in the `cid`, which is the index
of the heading relative to the original data set.
Parameters:
event (Event):
An application event.
cid (int):
A unique heading identifier; typically the index of the
heading relative to the original dataset.
"""
if event is not None:
eo = self._get_event_objects(event)
self.view.heading(eo.column.cid, anchor=W)
elif cid is not None:
self.view.heading(cid, anchor=W)
def align_heading_right(self, event=None, cid=None):
"""Right align the heading text. This can be triggered by
either an event, or by passing in the `cid`, which is the index
of the heading relative to the original data set.
Parameters:
event (Event):
An application event.
cid (int):
A unique heading identifier; typically the index of the
heading relative to the original dataset.
"""
if event is not None:
eo = self._get_event_objects(event)
self.view.heading(eo.column.cid, anchor=E)
elif cid is not None:
self.view.heading(cid, anchor=E)
def align_heading_center(self, event=None, cid=None):
"""Center align the heading text. This can be triggered by
either an event, or by passing in the `cid`, which is the index
of the heading relative to the original data set.
Parameters:
event (Event):
An application event.
cid (int):
A unique heading identifier; typically the index of the
heading relative to the original dataset.
"""
if event is not None:
eo = self._get_event_objects(event)
self.view.heading(eo.column.cid, anchor=CENTER)
elif cid is not None:
self.view.heading(cid, anchor=CENTER)
# PRIVATE METHODS
def _get_event_objects(self, event):
iid = self.view.identify_row(event.y)
col = self.view.identify_column(event.x)
cid = int(self.view.column(col, "id"))
column: TableColumn = self.cidmap.get(cid)
row: TableRow = self.iidmap.get(iid)
data = TableEvent(column, row)
return data
def _search_table_data(self, _):
"""Search the table data for records that meet search criteria.
Currently, this search locates any records that contain the
specified text; it is also case insensitive.
"""
criteria = self._searchcriteria.get()
self._filtered = True
self.tablerows_filtered.clear()
self.unload_table_data()
for row in self.tablerows:
for col in row.values:
if str(criteria).lower() in str(col).lower():
self.tablerows_filtered.append(row)
break
self._rowindex.set(0)
self.load_table_data()
# PRIVATE METHODS - SORTING
def _column_sort_header_reset(self):
"""Remove the sort character from the column headers"""
for col in self.tablecolumns:
self.view.heading(col.cid, text=col.headertext)
def _column_sort_header_update(self, cid):
"""Add sort character to the sorted column"""
column: TableColumn = self.cidmap.get(int(cid))
arrow = UPARROW if column.columnsort == ASCENDING else DOWNARROW
headertext = f"{column.headertext} {arrow}"
self.view.heading(column.cid, text=headertext)
# PRIVATE METHODS - WIDGET BUILDERS
def _build_tableview_widget(self, coldata, rowdata, bootstyle):
"""Build the data table"""
if self._searchable:
self._build_search_frame()
self.view = ttk.Treeview(
master=self,
columns=[x for x in range(len(coldata))],
height=self._height,
selectmode=EXTENDED,
show=HEADINGS,
bootstyle=f"{bootstyle}-table",
)
self.view.pack(fill=BOTH, expand=YES, side=TOP)
self.hbar = ttk.Scrollbar(
master=self, command=self.view.xview, orient=HORIZONTAL
)
self.hbar.pack(fill=X)
self.view.configure(xscrollcommand=self.hbar.set)
if self._paginated:
self._build_pagination_frame()
self.build_table_data(coldata, rowdata)
self._rightclickmenu_cell = TableCellRightClickMenu(self)
self._rightclickmenu_head = TableHeaderRightClickMenu(self)
self._set_widget_binding()
def _build_search_frame(self):
"""Build the search frame containing the search widgets. This
frame is only created if `searchable=True` when creating the
widget.
"""
frame = ttk.Frame(self, padding=5)
frame.pack(fill=X, side=TOP)
ttk.Label(frame, text=MessageCatalog.translate("Search")).pack(side=LEFT, padx=5)
searchterm = ttk.Entry(frame, textvariable=self._searchcriteria)
searchterm.pack(fill=X, side=LEFT, expand=YES)
searchterm.bind("<Return>", self._search_table_data)
searchterm.bind("<KP_Enter>", self._search_table_data)
if not self._paginated:
ttk.Button(
frame,
text=MessageCatalog.translate(""),
command=self.reset_table,
style="symbol.Link.TButton",
).pack(side=LEFT)
def _build_pagination_frame(self):
"""Build the frame containing the pagination widgets. This
frame is only built if `pagination=True` when creating the
widget.
"""
pageframe = ttk.Frame(self)
pageframe.pack(fill=X, anchor=N)
ttk.Button(
pageframe,
text=MessageCatalog.translate(""),
command=self.reset_table,
style="symbol.Link.TButton",
).pack(side=RIGHT)
ttk.Separator(pageframe, orient=VERTICAL).pack(side=RIGHT, padx=10)
ttk.Button(
master=pageframe,
text="»",
command=self.goto_last_page,
style="symbol.Link.TButton",
).pack(side=RIGHT, fill=Y)
ttk.Button(
master=pageframe,
text="",
command=self.goto_next_page,
style="symbol.Link.TButton",
).pack(side=RIGHT, fill=Y)
ttk.Button(
master=pageframe,
text="",
command=self.goto_prev_page,
style="symbol.Link.TButton",
).pack(side=RIGHT, fill=Y)
ttk.Button(
master=pageframe,
text="«",
command=self.goto_first_page,
style="symbol.Link.TButton",
).pack(side=RIGHT, fill=Y)
ttk.Separator(pageframe, orient=VERTICAL).pack(side=RIGHT, padx=10)
lbl = ttk.Label(pageframe, textvariable=self._pagelimit)
lbl.pack(side=RIGHT, padx=(0, 5))
ttk.Label(pageframe, text=MessageCatalog.translate("of")).pack(side=RIGHT, padx=(5, 0))
index = ttk.Entry(pageframe, textvariable=self._pageindex, width=4)
index.pack(side=RIGHT)
index.bind("<Return>", self.goto_page, "+")
index.bind("<KP_Enter>", self.goto_page, "+")
ttk.Label(pageframe, text=MessageCatalog.translate("Page")).pack(side=RIGHT, padx=5)
def _build_table_rows(self, rowdata):
"""Build, load, and configure the DataTableRow objects
Parameters:
rowdata (List):
An iterable of row data
"""
for row in rowdata:
self.insert_row(END, row)
def _build_table_columns(self, coldata):
"""Build, load, and configure the DataTableColumn objects
Parameters:
coldata (List[str|Dict[str, Any]]):
An iterable of column names or a dictionary of column
configuration settings.
"""
for cid, col in enumerate(coldata):
if isinstance(col, str):
self.tablecolumns.append(
TableColumn(
tableview=self,
cid=cid,
text=col,
)
)
else:
if "text" not in col:
col["text"] = f"Column {cid}"
self.tablecolumns.append(
TableColumn(tableview=self, cid=cid, **col)
)
# PRIVATE METHODS - WIDGET BINDING
def _set_widget_binding(self):
"""Setup the widget binding"""
self.view.bind("<Double-Button-1>", self._header_double_leftclick)
self.view.bind("<Button-1>", self._header_leftclick)
if self.tk.call("tk", "windowingsystem") == "aqua":
sequence = "<Button-2>"
else:
sequence = "<Button-3>"
self.view.bind(sequence, self._table_rightclick)
# add trace to track pagesize changes
self._pagesize.trace_add("write", self._trace_pagesize)
# def _select_pagesize(self, event):
# cbo: ttk.Combobox = self.nametowidget(event.widget)
# cbo.select_clear()
# self.goto_first_page()
def _trace_pagesize(self, *_):
"""Callback for changes to page size"""
self.goto_first_page()
def _header_double_leftclick(self, event):
"""Callback for double-click events on the tableview header"""
region = self.view.identify_region(event.x, event.y)
if region == "separator":
self.autofit_columns()
def _header_leftclick(self, event):
"""Callback for left-click events"""
region = self.view.identify_region(event.x, event.y)
if region == "heading":
self.sort_column_data(event)
def _table_rightclick(self, event):
"""Callback for right-click events"""
region = self.view.identify_region(event.x, event.y)
if region == "heading":
self._rightclickmenu_head.tk_popup(event)
elif region != "separator":
self._rightclickmenu_cell.tk_popup(event)
class TableCellRightClickMenu(tk.Menu):
"""A right-click menu object for the tableview cells - INTERNAL"""
def __init__(self, master: Tableview):
"""
Parameters:
master (Tableview):
The parent object
"""
super().__init__(master, tearoff=False)
self.master: Tableview = master
self.view: ttk.Treeview = master.view
self.cid = None
self.iid = None
config = {
"sortascending": {
"label": f'''{MessageCatalog.translate("Sort Ascending")}''',
"command": self.sort_column_ascending,
},
"sortdescending": {
"label": f'''{MessageCatalog.translate("Sort Descending")}''',
"command": self.sort_column_descending,
},
"clearfilter": {
"label": f'''{MessageCatalog.translate("")} {MessageCatalog.translate("Clear filters")}''',
"command": self.master.reset_row_filters,
},
"filterbyvalue": {
"label": f'''{MessageCatalog.translate("Filter by cell's value")}''',
"command": self.filter_to_cell_value,
},
"hiderows": {
"label": f'''{MessageCatalog.translate("Hide select rows")}''',
"command": self.hide_selected_rows,
},
"showrows": {
"label": f'''{MessageCatalog.translate("Show only select rows")}''',
"command": self.filter_to_selected_rows,
},
"exportall": {
"label": f'''{MessageCatalog.translate("Export all records")}''',
"command": self.export_all_records,
},
"exportpage": {
"label": f'''{MessageCatalog.translate("Export current page")}''',
"command": self.export_current_page,
},
"exportselection": {
"label": f'''{MessageCatalog.translate("Export current selection")}''',
"command": self.export_current_selection,
},
"exportfiltered": {
"label": f'''{MessageCatalog.translate("Export records in filter")}''',
"command": self.export_records_in_filter,
},
"moveup": {
"label": f'''{MessageCatalog.translate("Move up")}''',
"command": self.move_row_up
},
"movedown": {
"label": f'''{MessageCatalog.translate("Move down")}''',
"command": self.move_row_down,
},
"movetotop": {
"label": f'''{MessageCatalog.translate("Move to top")}''',
"command": self.move_row_to_top,
},
"movetobottom": {
"label": f'''{MessageCatalog.translate("Move to bottom")}''',
"command": self.move_row_to_bottom,
},
"alignleft": {
"label": f'''{MessageCatalog.translate("Align left")}''',
"command": self.align_column_left,
},
"aligncenter": {
"label": f'''{MessageCatalog.translate("Align center")}''',
"command": self.align_column_center,
},
"alignright": {
"label": f'''{MessageCatalog.translate("Align right")}''',
"command": self.align_column_right,
},
"deleterows": {
"label": f'''🞨 {MessageCatalog.translate("Delete selected rows")}''',
"command": self.delete_selected_rows,
},
}
sort_menu = tk.Menu(self, tearoff=False)
sort_menu.add_command(cnf=config["sortascending"])
sort_menu.add_command(cnf=config["sortdescending"])
self.add_cascade(menu=sort_menu, label=f'''{MessageCatalog.translate("Sort")}''')
filter_menu = tk.Menu(self, tearoff=False)
filter_menu.add_command(cnf=config["clearfilter"])
filter_menu.add_separator()
filter_menu.add_command(cnf=config["filterbyvalue"])
filter_menu.add_command(cnf=config["hiderows"])
filter_menu.add_command(cnf=config["showrows"])
self.add_cascade(menu=filter_menu, label=f'''{MessageCatalog.translate("Filter")}''')
export_menu = tk.Menu(self, tearoff=False)
export_menu.add_command(cnf=config["exportall"])
export_menu.add_command(cnf=config["exportpage"])
export_menu.add_command(cnf=config["exportselection"])
export_menu.add_command(cnf=config["exportfiltered"])
self.add_cascade(menu=export_menu, label=f'''{MessageCatalog.translate("Export")}''')
move_menu = tk.Menu(self, tearoff=False)
move_menu.add_command(cnf=config["moveup"])
move_menu.add_command(cnf=config["movedown"])
move_menu.add_command(cnf=config["movetotop"])
move_menu.add_command(cnf=config["movetobottom"])
self.add_cascade(menu=move_menu, label=f'''{MessageCatalog.translate("Move")}''')
align_menu = tk.Menu(self, tearoff=False)
align_menu.add_command(cnf=config["alignleft"])
align_menu.add_command(cnf=config["aligncenter"])
align_menu.add_command(cnf=config["alignright"])
self.add_cascade(menu=align_menu, label=f'''{MessageCatalog.translate("Align")}''')
self.add_command(cnf=config["deleterows"])
def tk_popup(self, event):
"""Display the menu below the selected cell.
Parameters:
event (Event):
The click event that triggers menu.
"""
# capture the column and item that invoked the menu
self.event = event
iid = self.view.identify_row(event.y)
col = self.view.identify_column(event.x)
# show the menu below the invoking cell
rootx = self.view.winfo_rootx()
rooty = self.view.winfo_rooty()
try:
bbox = self.view.bbox(iid, col)
except:
return
try:
super().tk_popup(rootx + bbox[0], rooty + bbox[1] + bbox[3])
except IndexError:
pass
def sort_column_ascending(self):
"""Sort the column in ascending order."""
self.master.sort_column_data(self.event, sort=ASCENDING)
def sort_column_descending(self):
"""Sort the column in descending order."""
self.master.sort_column_data(self.event, sort=DESCENDING)
def filter_to_cell_value(self):
"""Hide all records except for records where the current
column exactly matches the current cell value."""
self.master.filter_column_to_value(self.event)
def filter_to_selected_rows(self):
"""Hide all records except for the selected rows."""
self.master.filter_to_selected_rows()
def export_all_records(self):
"""Export all records to a csv file"""
self.master.export_all_records()
def export_current_page(self):
"""Export records on current page"""
self.master.export_current_page()
def export_current_selection(self):
"""Export rows currently selected"""
self.master.export_current_selection()
def export_records_in_filter(self):
"""Export rows currently filtered"""
self.master.export_records_in_filter()
def hide_selected_rows(self):
"""Hide the selected rows"""
self.master.hide_selected_rows()
def move_row_to_top(self):
"""Move the row to the top of the data set"""
self.master.move_selected_rows_to_top()
def move_row_to_bottom(self):
"""Move the row to the bottom of the dataset"""
self.master.move_selected_rows_to_bottom()
def move_row_up(self):
"""Move the selected above the previous sibling"""
self.master.move_selected_row_up()
def move_row_down(self):
"""Move the selected row below the next sibling"""
self.master.move_row_down()
def align_column_left(self):
"Left align the column text"
self.master.align_column_left(self.event)
def align_column_right(self):
"""Right align the column text"""
self.master.align_column_right(self.event)
def align_column_center(self):
"""Center align the column text"""
self.master.align_column_center(self.event)
def delete_selected_rows(self):
"""Delete the selected rows"""
iids = self.view.selection()
if len(iids) > 0:
# setting to prev should be in master?
prev_item = self.view.prev(iids[0])
self.master.delete_rows(iids=iids)
self.view.focus(prev_item)
self.view.selection_set(prev_item)
class TableHeaderRightClickMenu(tk.Menu):
"""A right-click menu object for the tableview header - INTERNAL"""
def __init__(self, master: Tableview):
"""
Parameters:
master (Tableview):
The parent object
"""
super().__init__(master, tearoff=False)
self.master: Tableview = self.master
self.view: ttk.Treeview = master.view
self.event = None
self.columnvars = []
self._show_menu = None
config = {
"movetoright": {
"label": f'''{MessageCatalog.translate("Move to right")}''',
"command": self.move_column_right,
},
"movetoleft": {
"label": f'''{MessageCatalog.translate("Move to left")}''',
"command": self.move_column_left,
},
"movetofirst": {
"label": f'''{MessageCatalog.translate("Move to first")}''',
"command": self.move_column_to_first,
},
"movetolast": {
"label": f'''{MessageCatalog.translate("Move to last")}''',
"command": self.move_column_to_last,
},
"alignleft": {
"label": f'''{MessageCatalog.translate("Align left")}''',
"command": self.align_heading_left,
},
"alignright": {
"label": f'''{MessageCatalog.translate("Align right")}''',
"command": self.align_heading_right,
},
"aligncenter": {
"label": f'''{MessageCatalog.translate("Align center")}''',
"command": self.align_heading_center,
},
"resettable": {
"label": f'''{MessageCatalog.translate("")} {MessageCatalog.translate("Reset table")}''',
"command": self.master.reset_table,
},
"deletecolumn": {
"label": f'''🞨 {MessageCatalog.translate("Delete column")}''',
"command": self.delete_column,
},
"hidecolumn": {
"label": f'''{MessageCatalog.translate("Hide column")}''',
"command": self.hide_column,
},
}
self.add_command(cnf=config["resettable"])
# HIDE & SHOW
self._build_show_menu()
self.add_cascade(menu=self._show_menu, label=f'''± {MessageCatalog.translate("Columns")}''')
self.add_separator()
# MOVE MENU
move_menu = tk.Menu(self, tearoff=False)
move_menu.add_command(cnf=config["movetoleft"])
move_menu.add_command(cnf=config["movetoright"])
move_menu.add_command(cnf=config["movetofirst"])
move_menu.add_command(cnf=config["movetolast"])
self.add_cascade(menu=move_menu, label=f'''{MessageCatalog.translate("Move")}''')
align_menu = tk.Menu(self, tearoff=False)
align_menu.add_command(cnf=config["alignleft"])
align_menu.add_command(cnf=config["aligncenter"])
align_menu.add_command(cnf=config["alignright"])
self.add_cascade(menu=align_menu, label=f'''{MessageCatalog.translate("Align")}''')
self.add_command(cnf=config["hidecolumn"])
self.add_command(cnf=config["deletecolumn"])
def tk_popup(self, event):
# capture the column and item that invoked the menu
self.event = event
self._build_show_menu()
# show the menu below the invoking cell
rootx = self.view.winfo_rootx()
rooty = self.view.winfo_rooty()
super().tk_popup(rootx + event.x, rooty + event.y + 10)
def _build_show_menu(self):
"""Build the show menu based on currently available columns"""
if self._show_menu is not None:
self._show_menu.delete(0, END)
else:
self._show_menu = tk.Menu(self, tearoff=False)
self._show_menu.add_command(
label=MessageCatalog.translate("Show All"), command=self.show_all_columns
)
self._show_menu.add_separator()
displaycolumns = [x.cid for x in self.master.tablecolumns_visible]
for column in self.master.tablecolumns:
varname = f"column_{column.cid}"
# self.columnvars.append(tk.Variable(name=varname, value=True))
self._show_menu.add_checkbutton(
label=column._headertext,
command=lambda w=column: self.toggle_columns(w.cid),
variable=varname,
onvalue=True,
offvalue=False,
)
if column.cid in displaycolumns:
self.setvar(varname, True)
else:
self.setvar(varname, False)
def toggle_columns(self, cid):
"""Toggles the visibility of the selected column"""
variable = f"column_{cid}"
toggled = self.getvar(variable)
if toggled:
self.master.unhide_selected_column(cid=int(cid))
else:
self.master.hide_selected_column(cid=int(cid))
def show_all_columns(self):
"""Show all columns"""
for var in self.columnvars:
var.set(value=True)
self.master.reset_column_filters()
def move_column_left(self):
"""Move column one position to the left"""
self.master.move_column_left(self.event)
def move_column_right(self):
"""Move column on position to the right"""
self.master.move_column_right(self.event)
def move_column_to_first(self):
"""Move column to leftmost position"""
self.master.move_column_to_first(self.event)
def move_column_to_last(self):
"""Move column to rightmost position"""
self.master.move_column_to_last(self.event)
def align_heading_left(self):
"""Left align the column header"""
self.master.align_heading_left(self.event)
def align_heading_right(self):
"""Right align the column header"""
self.master.align_heading_right(self.event)
def align_heading_center(self):
"""Center align the column header"""
self.master.align_heading_center(self.event)
def delete_column(self):
"""Delete the selected column"""
eo = self.master._get_event_objects(self.event)
eo.column.delete()
def hide_column(self):
"""Hide the selected column"""
eo = self.master._get_event_objects(self.event)
eo.column.hide()