import tkinter.ttk as ttk
from collections.abc import Iterable
from contextlib import suppress
from tkinter import END
from .base import Base
from .entries import CellEntry
from .tabulardata import TabularData
[docs]
class TableView(Base):
"""A table widget wrapping tkinter Treeview with data binding and editing support."""
[docs]
class DelegateError(Exception):
"""Raised when a delegate callback raises an unexpected error."""
def __init__(self, columns_labels, is_treetable=False, create_data_source=True):
Base.__init__(self)
if not isinstance(columns_labels, dict):
raise TypeError(
"column_labels must be a dictionary with {'column_name':'column_label'}"
)
self._columns_labels = columns_labels # keep until widget created
self.is_treetable = is_treetable
self.column_formats = {} # Dict with column_name: 'format_string', 'multiplier', 'type','anchor'
self.delegate = None
self.all_elements_are_editable = True
if create_data_source:
self.data_source = TabularData(
tableview=self, required_fields=list(columns_labels.keys())
)
else:
self.data_source = None
@property
def columns(self):
"""Return the list of column names."""
if self.widget is not None:
return list(self.widget["columns"])
else:
return list(self._columns_labels.keys())
@columns.setter
def columns(self, new_values):
if self.widget is not None:
# If displaycolumns has been set, it cannot contain columns
# that are being deleted. We start by removing them from displaycolumns
# We will modify the list, we need to make a copy
# displayed_column_names = self.displaycolumns.copy()
# for old_column_name in displayed_column_names:
# if old_column_name not in new_values:
# self.displaycolumns.remove(old_column_name)
self.displaycolumns = ["#all"] # necessary to avoid TCLError when setting columns
self.widget["columns"] = new_values
self.displaycolumns = new_values # We refer to displaycolumns to get displayed order
else:
raise ValueError("Set columns-labels directly if the widget is not created yet.")
[docs]
def column_info(self, cid):
"""Return column configuration info for the given column identifier."""
if self.widget is not None:
return self.widget.column(cid)
else:
raise TableView.WidgetNotYetCreatedError()
@property
def displaycolumns(self):
"""Return the list of currently displayed column names."""
if self.widget is not None:
return list(self.widget["displaycolumns"])
else:
return list(self._columns_labels.keys())
@displaycolumns.setter
def displaycolumns(self, values):
if self.widget is None:
return
if isinstance(values, Iterable) and len(values) == 0:
import warnings
warnings.warn("empty displaycolumns will display nothing", stacklevel=2)
self.widget.configure(displaycolumns=values)
@property
def headings(self):
"""Return the list of column heading labels."""
headings = []
if self.widget is not None:
for column_name in self.columns:
treeview_heading = self.widget.heading(column_name)
headings.append(treeview_heading["text"])
else:
headings = list(self._columns_labels.values())
return headings
@headings.setter
def headings(self, new_values):
if len(new_values) != len(self.columns):
raise ValueError(
f"Expected {len(self.columns)} headings, got {len(new_values)}"
)
new_columns_labels = dict(zip(self.columns, new_values, strict=False))
if self.widget is not None:
for column_name, column_heading in new_columns_labels.items():
self.widget.heading(column_name,text=column_heading)
else:
self._columns_labels = dict(zip(self.columns, new_values, strict=False))
@property
def columns_labels(self):
"""Return a dict mapping column names to their heading labels."""
if self.widget is not None:
return dict(zip(self.columns, self.headings, strict=False))
else:
return self._columns_labels
@columns_labels.setter
def columns_labels(self, new_values):
if self.widget is not None:
self.columns = list(new_values.keys())
self.headings = list(new_values.values())
else:
self._columns_labels = new_values
[docs]
def heading_info(self, cid):
"""Return heading configuration info for the given column identifier."""
if self.widget is not None:
return self.widget.heading(cid)
else:
raise TableView.WidgetNotYetCreatedError()
[docs]
def item_info(self, iid):
"""Return item configuration info for the given item identifier."""
if self.widget is not None:
return self.widget.item(iid)
else:
raise TableView.WidgetNotYetCreatedError()
[docs]
def source_data_changed(self, records):
"""Update the widget to reflect changes in the data source records."""
if self.widget is None:
return
self.source_data_added_or_updated(records)
self.source_data_deleted(records)
if self.delegate is not None and hasattr(self.delegate, "source_data_changed"):
self.delegate.source_data_changed(self)
[docs]
def source_data_added_or_updated(self, records):
"""Insert new records or update existing ones in the widget."""
for record in records:
formatted_values = self.record_to_formatted_widget_values(record)
item_id = record["__uuid"]
if self.widget.exists(item_id): # updated
for i, value in enumerate(formatted_values):
self.widget.set(item_id, column=i, value=value)
else: # added
parentid = ""
if record["__puuid"] is not None:
parentid = record["__puuid"]
self.widget.insert(parentid, END, iid=item_id, values=formatted_values)
[docs]
def source_data_deleted(self, records):
"""Remove widget items that are no longer present in the data source."""
uuids = [str(record["__uuid"]) for record in records]
items_ids = self.items_ids()
for item_id in items_ids:
if item_id not in uuids:
self.widget.delete(item_id)
[docs]
def items_ids(self):
"""Return all item identifiers in the widget, including nested children."""
all_item_ids = []
parent_items_ids = [None]
while len(parent_items_ids) > 0:
all_children_item_ids = []
for item_id in parent_items_ids:
children_items_ids = self.widget.get_children(item_id)
all_item_ids.extend(children_items_ids)
all_children_item_ids.extend(children_items_ids)
parent_items_ids = all_children_item_ids
return all_item_ids
[docs]
def item_modified(self, item_id, modified_record):
"""Update a widget item and its backing data source record."""
ordered_values = self.record_to_formatted_widget_values(modified_record)
self.widget.item(item_id, values=ordered_values)
self.data_source.update_record(item_id, values=modified_record)
[docs]
def clear_widget_content(self):
"""Delete all items from the widget and return their identifiers."""
items_ids = self.widget.get_children()
self.widget.delete(*items_ids)
return items_ids
[docs]
def empty(self):
"""Remove all items from the table."""
if self.data_source is not None:
self.data_source.remove_all_records()
[docs]
def selection_changed(self, event):
"""Notify the delegate when the table selection changes."""
if self.delegate is not None and hasattr(self.delegate, "selection_changed"):
self.delegate.selection_changed(event, self)
[docs]
def identify_column_name(self, event_x):
"""Return the column name at the given x pixel coordinate."""
column_string_id = self.widget.identify_column(event_x)
if column_string_id == '#0':
return column_string_id
display_column_number = int(column_string_id.strip("#"))
column_name = self.displaycolumns[display_column_number-1]
return column_name
[docs]
def get_column_name(self, column_id=None, display_column_number=None):
"""Return the column name for a given column id or display column number."""
if column_id is not None:
# column_id is 1-based, our list is 0-based
if column_id == 0:
return "#0"
return self.columns[column_id-1]
elif display_column_number is not None:
# display_column_number 0 is the icon column
if display_column_number == 0:
return "#0"
return self.displaycolumns[display_column_number-1]
[docs]
def get_column_id(self, column_name):
"""Return the 1-based column id for the given column name."""
# column_id is 1-based, our list is 0-based
# The items "values" are accessible by column_id-1
if column_name == "#0":
return 0
return self.columns.index(column_name)+1
[docs]
def get_logical_column_id(self, column_name):
"""Return the 0-based logical column index used to access item values."""
# logical_column_id is 0-based and used to access item['values']
if column_name == "#0":
return None # We do not store the icon
return self.columns.index(column_name)
[docs]
def click(self, event) -> bool: # pragma: no cover
"""Handle a single click event on the table."""
keep_running = True
if self.delegate is not None and hasattr(self.delegate, "click"):
try:
keep_running = self.delegate.click(event, self)
except Exception as err:
raise TableView.DelegateError(err) from err
if keep_running:
region = self.widget.identify_region(event.x, event.y)
if region == "heading":
column_name = self.identify_column_name(event.x)
self.click_header(column_name=column_name)
elif region == "cell":
column_name = self.identify_column_name(event.x)
item_id = self.widget.identify_row(event.y)
self.click_cell(item_id=item_id, column_name=column_name)
return True
[docs]
def click_cell(self, item_id, column_name): # pragma: no cover
"""Handle a single click on a specific cell, opening URLs if applicable."""
assert isinstance(column_name, str)
item_dict = self.widget.item(item_id)
keep_running = True
if self.delegate is not None and hasattr(self.delegate, "click_cell"):
try:
keep_running = self.delegate.click_cell(
item_id, column_name, self
)
except Exception as err:
raise TableView.DelegateError(err) from err
if keep_running:
logical_column_id = self.get_logical_column_id(column_name)
value = item_dict["values"][logical_column_id]
if isinstance(value, str) and value.startswith("http"):
import webbrowser
webbrowser.open(value)
return True
[docs]
def is_column_sorted(self, column_name):
"""Return '<' if sorted ascending, '>' if descending, or None if unsorted."""
assert isinstance(column_name, str)
original_items_ids = list(self.widget.get_children())
sorted_items_ids = list(self.sorted_column(column_name=column_name, reverse=False))
sorted_items_ids_reverse = list(self.sorted_column(column_name=column_name, reverse=True))
if sorted_items_ids == original_items_ids:
return "<"
elif sorted_items_ids_reverse == original_items_ids:
return ">"
else:
return None
[docs]
def sorted_column(self, column_name=None, reverse=False):
"""Return item identifiers sorted by the given column."""
assert isinstance(column_name, str)
if column_name == "#0":
return self.widget.get_children()
# HACK We sort only what is actually in the widget (may be filtered)
widget_items_ids = self.items_ids()
items_ids_sorted = self.data_source.sorted_records_uuids(
only_uuids=widget_items_ids, field=column_name, reverse=reverse
)
return items_ids_sorted
[docs]
def sort_column(self, column_name=None, reverse=False):
"""Sort the widget items in place by the given column."""
assert isinstance(column_name, str)
items_ids_sorted = self.sorted_column(column_name=column_name, reverse=reverse)
for _, item_id in enumerate(items_ids_sorted):
record = self.data_source.record(item_id)
parent_id = record['__puuid']
if parent_id is None:
parent_id = ""
self.widget.move(record['__uuid'], parent_id , END)
return items_ids_sorted
[docs]
def doubleclick(self, event) -> bool: # pragma: no cover
"""Handle a double-click event on the table."""
keep_running = True
if self.delegate is not None and hasattr(self.delegate, "doubleclick"):
try:
keep_running = self.delegate.doubleclick(event, self)
except Exception as err:
raise TableView.DelegateError(err) from err
if keep_running:
region = self.widget.identify_region(event.x, event.y)
column_name = self.identify_column_name(event.x)
if region == "heading":
self.doubleclick_header(column_name=column_name)
elif region == "cell":
item_id = self.widget.identify_row(event.y)
self.doubleclick_cell(item_id=item_id, column_name=column_name)
# return True
[docs]
def is_editable(self, item_id, column_name):
"""Return whether the given cell is editable."""
return self.all_elements_are_editable
[docs]
def doubleclick_cell(self, item_id, column_name):
"""Handle a double-click on a cell, entering edit mode if editable."""
assert isinstance(column_name, str)
_item_dict = self.widget.item(item_id)
if self.is_editable(item_id, column_name=column_name):
self.focus_edit_cell(item_id=item_id, column_name=column_name)
else:
_keep_running = True
if self.delegate is not None and hasattr(self.delegate, "doubleclick_cell"):
try:
self.delegate.doubleclick_cell(item_id, column_name, self)
except Exception as err:
raise TableView.DelegateError(err) from err
[docs]
def focus_edit_cell(self, item_id, column_name):
"""Place an entry widget over the cell for inline editing."""
assert isinstance(column_name, str)
bbox = self.widget.bbox(item_id, column=column_name)
entry_box = CellEntry(tableview=self, item_id=item_id, column_name=column_name)
entry_box.place_into(
parent=self,
x=bbox[0] - 2,
y=bbox[1] - 2,
width=bbox[2] + 4,
height=bbox[3] + 4,
)
entry_box.widget.focus()