"""
Contains the implementation of the class TreeviewDataFrame.
"""
import tkinter as tk
from tkinter import ttk
from typing import Any, Literal
import pandas as pd
from tkextras import WidgetsRender
[docs]
class TreeviewDataFrame(WidgetsRender, ttk.Treeview):
"""
Special tree implementation for working with boolean marks (by default {"check": "✔", "uncheck": " "}).
Supports optional Filtering and "mark all" widgets.
Simple loading and unloading of a dataframe containing the current state of the tree for further work.
"""
_svars = {
"flag_symbol": {
"check": "✔",
"uncheck": " "
},
"check_all": {}
}
_svars["flag_values"] = {
_svars["flag_symbol"]["uncheck"]: _svars["flag_symbol"]["check"],
_svars["flag_symbol"]["check"]: _svars["flag_symbol"]["uncheck"]
}
def __init__(self, parent: tk.Widget | tk.Tk, dataframe: pd.DataFrame = None, render_params: dict = None,
*args, **kwargs):
"""
:param parent:
:param dataframe:
:param render_params:
:param args:
:param kwargs:
"""
super().__init__(render_params, parent, *args, **kwargs)
self.df = pd.DataFrame(columns=self.cget("columns"))
self.filtered_df = self.df.copy()
self.bind("<Button-1>", self.toggle_cell)
if not (dataframe is None):
self.make_tree(dataframe)
[docs]
def make_tree(self, df: pd.DataFrame = None):
"""
Builds the tree according to the dataframe
:param df: the dataframe for building
:return: None
"""
if not (df is None):
cols = df.columns.to_list()
if len(self.df):
self.delete(*self.get_children(), inplace=True)
for index, row in df.iterrows():
self.insert("", "end", values=tuple(row))
else:
cols = self.df.columns.to_list()
col_index = 0
for col in cols:
self.heading(col, text=col.capitalize())
if col_index == 0:
self.column(col, width=200, anchor="w")
col_index = 1
else:
self.column(col, width=100, anchor="center")
@property
def svars(self):
"""
The attribute that automatically creates a copy _svars, so that each object has an isolated copy
:return: dict, isolated svars
"""
return self._svars.copy()
[docs]
def column(self, column: str | int, option=None, **kw):
"""
Override column method with DataFrame.
"""
result = super().column(column, option=option, **kw)
if column not in self.df.columns:
self.df[column] = ''
return result
[docs]
def insert(self, parent: str, index: int | Literal["end"], iid: str | int | None = None, **kw):
"""
Inserts a new row into the Treeview and synchronizes it with the DataFrame.
:param parent: Parent node for Treeview (usually "" for root-level items).
:param index: Position to insert the item.
:param iid: Unique identifier for the row. If None, Treeview generates one.
:param kw: Additional arguments for Treeview insert (e.g., values).
"""
# Use the provided iid or let Treeview generate one
if iid is None:
iid = super().insert(parent, index, **kw) # Automatically generate iid
else:
super().insert(parent, index, iid=iid, **kw)
# Ensure values are provided
values = kw.get("values", [])
# Convert values to a DataFrame-compatible dictionary
new_row = {col: val for col, val in zip(self.cget("columns"), values)}
# Add the new row to the DataFrame, using iid as the index
self.df.loc[iid] = new_row
return iid
[docs]
def set(self, item: str | int, column: None = None, value: None = None) -> dict[str, Any]:
"""
Enhanced tt.Treeview set method for synchronization with a DataFrame.
:param item: The item ID (iid) in the Treeview.
:param column: The column name to retrieve or update.
:param value: The value to set; if None, retrieves the current value.
:return: The value as returned by the original Treeview method.
"""
result = super().set(item, column, value)
if item not in self.df.index:
raise KeyError(f"Row with index '{item}' not found in DataFrame.")
is_filtered = True if item in self.filtered_df.index else False
if value is None:
if column is None:
self.df.loc[item] = self.df.loc[item].replace(result)
else:
self.df.loc[item, column] = result
else:
self.df.loc[item, column] = value
if is_filtered:
self.filtered_df.loc[item, column] = value
ind = self.cget("columns").index(column) if not column else 0
self.all_checked_update(ind)
return result
[docs]
def item(self, item: str | int, option: Literal["text"] | None = None, **kw) -> str:
"""
Override tk.Treeview item method with DataFrame synchronization.
:param item:
:param option:
:param kw:
:return:
"""
values = kw.get("values", [])
result = super().item(item, option, **kw) # noqa
is_filtered = True if item in self.filtered_df.index else False
if option is None and len(values):
updates = pd.Series(values, index=self.cget("columns"))
self.df.loc[item] = updates
if is_filtered:
self.filtered_df.loc[item] = updates
self.all_checked_update()
return result
[docs]
def delete(self, *items: str | int, inplace=False):
"""
Override tk.Treeview delete method with DataFrame synchronization.
:param items:
:param inplace:
:return:
"""
if inplace:
for item in items:
values = self.item(item, "values") # noqa
self.df = self.df[~(self.df[list(self.df.columns)] == values).all(axis=1)]
super().delete(*items)
[docs]
def flag_inverse(self, value: str) -> str:
"""
Inverts the state of the cell flag
:param value: incoming flag
:return: inverted flag
"""
flag_values = self.svars["flag_values"]
return flag_values[value]
[docs]
def toggle_cell(self, event):
"""
Handles cell clicks to change flags.
:param event: click coordinates
:return: None if the click is outside the target area
"""
if self.identify_region(event.x, event.y) != "cell":
return
col_num = int(self.identify_column(event.x).replace("#", "")) - 1
if not col_num:
return
col_name = self.cget("columns")[col_num]
item = self.identify_row(event.y)
current_value = self.set(item, col_name)
self.set(item, col_name, self.flag_inverse(current_value)) # noqa
self.event_generate("<<TreeToggleCell>>")
[docs]
def rebuild_tree(self, dataframe: pd.DataFrame = None):
"""
Rebuilds the tree according to the dataframe
:param dataframe: dataframe for rebuilding, if empty, self.df is used
:return: None
"""
if dataframe is None:
dataframe = self.df
self.delete(*self.get_children())
for index, row in dataframe.iterrows():
self.insert("", "end", iid=str(index), values=row.to_list())
[docs]
def filter_by_name(self, keyword: str = ""):
"""
Filter DataFrame rows based on a keyword and update Treeview.
:param keyword: filter string
:return: None
"""
self.filtered_df = self.df[self.df[self.df.columns[0]].str.contains(keyword, case=False)].copy()
self.rebuild_tree(self.filtered_df)
[docs]
def filter_event_evoke(self):
"""
Filter updated event.
:return: None
"""
self.event_generate("<<TreeFilterUpdated>>")
[docs]
def all_checked_event_evoke(self):
"""
Generation of an all_checked flag updated event
:return:
"""
self.event_generate("<<TreeCheckAllUpdated>>")
[docs]
def is_all_checked(self, column: int) -> bool:
"""
Checking the column status (all cells are marked)
:param column: column number
:return: column status
"""
df = self.filtered_df if len(self.filtered_df) else self.df
return not len(df[df.iloc[:, column] == self.svars["flag_symbol"]["uncheck"]])
[docs]
def all_checked_update(self, column: int = 0):
"""
Update the state of all (or one) flags, if column is not 0.
:param column: column number
:return: None
"""
if not len(self.svars["check_all"]):
return
if column:
self.svars['check_all'][column].set(self.is_all_checked(column)) # noqa
else:
for i in range(1, len(self.cget("columns"))):
self.svars['check_all'][i].set(self.is_all_checked(i)) # noqa
self.all_checked_event_evoke()