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 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("", self._search_table_data) searchterm.bind("", 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("", self.goto_page, "+") index.bind("", 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("", self._header_double_leftclick) self.view.bind("", self._header_leftclick) if self.tk.call("tk", "windowingsystem") == "aqua": sequence = "" else: sequence = "" 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()