Source code for mytk.tableview

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."""
[docs] class WidgetNotYetCreatedError(Exception): """Raised when accessing widget properties before the widget is created."""
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 create_widget(self, master): """Create the underlying Treeview widget within the given master container.""" self.parent = master if self.is_treetable: self.widget = ttk.Treeview( master, selectmode="browse", takefocus=True, ) else: self.widget = ttk.Treeview( master, show="headings", selectmode="browse", takefocus=True, ) self.widget.configure(columns=list(self._columns_labels.keys())) self.widget.configure(displaycolumns=list(self._columns_labels.keys())) for key, value in self._columns_labels.items(): self.widget.heading(key, text=value) self.widget.bind("<Button>", self.click) self.widget.bind("<Double-Button>", self.doubleclick) self.widget.bind("<<TreeviewSelect>>", self.selection_changed)
[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 record_to_formatted_widget_values(self, record): """Convert a data record to a list of formatted strings for display.""" ordered_values = [record[column] for column in self.columns] formatted_values = [] for i, value in enumerate(ordered_values): padding = "" column_name = self.columns[i] if len(self.displaycolumns) > 0 and column_name == self.displaycolumns[0]: level = record.get("__depth_level", 0) padding = " " * level column_format = self.column_formats.get(column_name, None) try: if value is None: value = "" if column_format is not None: format_string = column_format['format_string'] multiplier = column_format['multiplier'] if multiplier is not None: formatted_values.append( padding + format_string.format(value/multiplier) ) else: formatted_values.append( padding + format_string.format(value) ) else: formatted_values.append( padding + str(value) ) except Exception: formatted_values.append( padding + str(value) ) return formatted_values
[docs] def extract_record_from_formatted_widget_values(self): """Extract a record from the formatted widget values (not implemented).""" return None
[docs] def widget_data_changed(self): """Handle notification that the widget data has changed.""" pass
[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 click_header(self, column_name=None): """Handle a click on a column header, toggling sort order.""" assert isinstance(column_name, str) keep_running = True if column_name not in self.columns: raise ValueError(f"click_header: '{column_name}' is not the name of a column") if self.delegate is not None and hasattr(self.delegate, "click_header"): try: keep_running = self.delegate.click_header(column_name, self) except Exception as err: raise TableView.DelegateError(err) from err if keep_running: with suppress(IndexError): # if empty, not an error if self.is_column_sorted(column_name) == "<": self.sort_column(column_name=column_name, reverse=True) else: self.sort_column(column_name=column_name, reverse=False) return True
[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()
[docs] def doubleclick_header(self, column_name): # pragma: no cover """Handle a double-click on a column header.""" assert isinstance(column_name, str) if self.delegate is not None and hasattr(self.delegate, "doubleclick_header"): try: self.delegate.doubleclick_header(column_name, self) except Exception as err: raise TableView.DelegateError(err) from err