-
Notifications
You must be signed in to change notification settings - Fork 55
Description
I've been working with this library and ended up creating a table that implements several functions. While these aren't definitive solutions ready to be added directly to the library, I think they could be valuable ideas or additions to the library.
In short, I realized, among other things, that there wasn't a built-in way to sort the table rows in a custom way. So I created a function for that. It's not perfect, but it allows me to sort the rows according to my own criteria, which is really useful. I also saw the need for better row management, so I implemented options to delete empty rows, delete all rows at once, or delete only selected rows. I also generated a unique hash dependent on the row, so I could retrieve the data from that row without relying on the data itself, which can change during program execution. These hashes are hidden from the table view. Speaking of which, it would be nice to be able to incorporate individual columns or data into rows that the user doesn't see, but that can be accessed from the code. This would make things much easier. These are basic functions, but they have been quite useful.
I also had to address data integration. Since I work a lot with JSON, I designed a simple mapping between JSON keys and table column headers. This allows me to convert data more easily. It's a simple approach that has saved me a lot of time.
Now, as for interactivity, I wanted to make the table more interactive. I was surprised to discover there was no built-in event when a cell changed, so I created a "listener" that notifies me whenever a value changes, displaying both the old and new values. I also tried my best to detect when the mouse entered or left a cell, which was a bit tricky, but I got it right! This is useful for visual effects or quick messages.
Finally, the auto-width feature for columns wasn't working as I expected. So I wrote my own version: my code attempts to ensure the text fits properly, adjusting the width or inserting line breaks if it's too long, so everything remains legible without exceeding the maximum width. Although it doesn't work entirely well either, I'm working on it. It's a practical solution that improves readability.
All of these functions, and more, are available for review and use in the next class. It is independent and extends tkframe, so you can copy and paste it into your project and as long as you have tksheet you should be able to easily implement it in your interface:
import tkinter as tk
from tkinter import font
import uuid
from tksheet import Sheet
from typing import Any, Callable, Final, List, Dict, Optional, Tuple, Union
from services.NavegadorServices import NavegadorServices
from infraestructure.Señal import Señal
class Table(tk.Frame):
"""
Table component based on tksheet to display and manage data.
Encapsulates the creation of the Sheet, resizing bindings, and methods
for inserting data, assigning values, and handling checkboxes.
"""
DEFAULT_HEADER: Final[str] = "Selected"
ALL_HEADERS: Final[str] = "all"
MAX_COLUMN_WIDTH_DEFAULT: Final[int] = 190
def __init__(self, parent: tk.Widget, headers: List[str] = None, **sheet_kwargs):
"""
Initializes the table within a Frame.
:param parent: Parent widget where the table will be embedded.
:param headers: List of headers (columns). If None, it will be configured
when data is loaded for the first time.
:param sheet_kwargs: Additional arguments to pass to Sheet (height, width, etc.).
"""
super().__init__(parent)
self.table = Sheet(
self,
headers=headers or [],
total_rows=0,
show_row_index=False,
data=[],
**sheet_kwargs,
)
self.table.enable_bindings(
(
"single_select",
"row_select",
"cell_select",
"drag_select",
"edit_header",
"sheet_modified",
"ctrl_click_select",
"column_select",
"arrowkeys",
"rc_select",
"copy",
"cut",
"paste",
"delete",
"undo",
"text_editor",
"edit_cell",
"right_click_popup_menu",
"column_width_resize",
"resize",
"checkbox",
"checkbox_select",
)
)
self.table.pack(fill="both", expand=True, padx=5, pady=4)
self._mapping: Dict[str, str] = {
self.DEFAULT_HEADER.lower(): self.DEFAULT_HEADER
}
self._row_hashes = {}
self._headers = {}
self._init_signals()
self._set_events()
self._configure_default_wrap_options()
self.table.set_all_cell_sizes_to_text(
width=self.MAX_COLUMN_WIDTH_DEFAULT, redraw=True
)
def _set_events(self):
self.bind("<Configure>", self._on_window_resize)
self.table.bind("<Delete>", self._on_delete)
self.table.bind("<ButtonRelease-1>", self._on_header_resize)
self.table.bind("<<SheetModified>>", self._emit_add_data)
self.table.extra_bindings(
"begin_edit_header",
func=self._emit_header_double_click,
)
self.activate_cell_enter_event()
self.activate_cell_change_event()
self.register_slot_for_header_double_click(
self.DEFAULT_HEADER,
lambda: self.sort_by_priority(
self.DEFAULT_HEADER, [True, False]
),
)
def _on_header_resize(self, event):
if event.widget.current_cursor == "sb_h_double_arrow":
col = self.table.identify_column(event)
new_width = self.table.column_width(col)
self.adjust_column_text(col, new_width)
def _init_signals(self):
self._header_double_click_signals: Dict[Optional[str], Señal] = {None: Señal()}
self.edit_cell: Señal = Señal()
self.delete_rows: Señal = Señal()
self.mark_checkboxes: Señal = Señal()
self.add_data: Señal = Señal()
self.enter_cell: Señal = Señal()
def _configure_default_wrap_options(self):
num_cols = len(self.table.headers() or [])
if num_cols > 0:
for col_idx in range(num_cols):
# Ensure all new columns have wrap enabled by default
# so that row height adjustment works correctly.
self.table.column_options(col_idx, wrap=True)
# Also for future columns if the table is initialized without headers
self.table.set_options(default_column_options={"wrap": True})
self.table.set_options(
default_row_options={"wrap": True}
) # For individual cells as well
self.table.set_options(default_header_options={"wrap": True})
def _on_delete(self, event=None):
json_data = self.get_selected_data_as_json() # TODO: Can be optimized
self._delete_selected_rows()
self.delete_rows.emit(json_data)
def _delete_selected_rows(self):
# Get selected cells (as tuples (row, column))
cells = self.table.get_selected_cells()
# Get full selected rows
rows = self.table.get_selected_rows()
# Extract only row indices from cells
rows_from_cells = {cell[0] for cell in cells}
# Combine both selection sources
rows_to_delete = sorted(set(rows) | rows_from_cells, reverse=True)
# Delete rows from lowest to avoid re-indexing
for row in rows_to_delete:
self.table.delete_row(row, emit_event=True)
del self._row_hashes[row]
def _on_window_resize(self, event: Optional[tk.Event] = None):
"""
When the Frame resizes, adjusts column widths equally.
Can be called without event to adjust manually.
"""
self.table.redraw()
# 1) Determine total width
if isinstance(event, tk.Event):
total_width = event.width
else:
total_width = self.winfo_width()
cols = self.table.headers() or []
num_cols = len(cols)
if num_cols == 0 or total_width <= 0:
return
# 2) Calculate width per column
col_w = total_width // num_cols
for idx in range(num_cols):
self.table.column_width(idx, width=col_w)
def _build_priority_mapping_for_sorting(
order_list: List[Any], col_idx: int, default_pos: int = None
) -> Callable[[List[Any]], int]:
"""
Returns a function that can be used as a key in sorted() to sort rows
according to a defined priority order in order_list.
:param order_list: list with values in the desired order,
e.g., ["good1", "good2", ..., "bad1", "bad2"]
:param col_idx: column index to search for values in each row
:param default_pos: position for any value NOT listed in order_list.
If None, it will be placed at the end (len(order_list))
:return: key function(row: List[Any]) -> int
"""
mapping = {val: idx for idx, val in enumerate(order_list)}
if default_pos is None:
default_pos = len(order_list)
def key_fn(row: List[Any]) -> int:
return mapping.get(row[col_idx], default_pos)
return key_fn
def define_header_mapping(self, mapping: dict):
"""
Defines the mapping between internal keys and visible header names,
automatically removing any key whose value is duplicated.
Parameters:
----------
mapping : dict
Dictionary that maps internal keys to visible names in the table.
"""
used_values = set()
deduplicated_mapping = {}
for key, value in mapping.items():
if value not in used_values:
deduplicated_mapping[key] = value
used_values.add(value)
# If the value is already present, we simply ignore it
self._mapping = deduplicated_mapping
# Create inverse mapping: visible name -> internal key
self._inverse_mapping = {v: k for k, v in self._mapping.items()}
def set_headers(
self,
data_headers: Union[List[str], Dict[int, str]],
clear_existing: bool = True
):
"""
Updates data headers without touching the "Selected" column.
Parameters:
data_headers (list[str] | dict[int, str]): Ordered list of headers or dictionary with index -> header.
clear_existing (bool): If True, replaces current headers. If False, appends them.
"""
# Convert to list if it comes as a dict with indices
if isinstance(data_headers, dict):
# Sort by key (index) and extract values
sorted_headers = [data_headers[i] for i in sorted(data_headers)]
else:
sorted_headers = data_headers
if clear_existing:
self._headers = sorted_headers
else:
self._headers += sorted_headers
# Capitalize headers
capitalized = [h.capitalize() for h in self._headers]
# Add "Selected" at the beginning if not present
full = capitalized
if self.DEFAULT_HEADER.capitalize() not in capitalized:
full = [self.DEFAULT_HEADER] + capitalized
self.table.headers(full)
self._on_window_resize()
def get_headers(self) -> List[str]:
"""
Returns the list of headers in the current column order.
:return: List of strings with the headers.
"""
return self.table.headers()
def insert_data(
self, list_of_dicts: List[Dict], clear_previous_rows: bool = False, column_x_key: Tuple[bool, bool] = [True, False]
) -> Dict[int, str]:
"""
Inserts a series of data into the graphical table from a list of dictionaries,
managing columns, rows, internal mapping, and hash generation.
Args:
list_of_dicts (List[Dict]):
List of dictionaries, where each represents a row.
clear_previous_rows (bool, optional):
If True, clears all existing rows in the table before inserting new data.
This allows for a "clean" load of information. Defaults to False.
column_x_key (Tuple[bool, bool], optional):
Double parameter indicating how to handle key (column) mapping:
- column_x_key[0] (bool): If True, forces the key mapping to be updated.
- column_x_key[1] (bool): If True, previous columns are removed before inserting new ones.
This parameter allows controlling column behavior based on incoming content.
Returns:
Dict[int, str]:
A dictionary that maps the index of each inserted row with its unique hash (UUID).
This hash can be used to identify the row in future operations without relying on visible data.
"""
if not list_of_dicts:
return {}
if clear_previous_rows:
self.delete_all_rows()
if not self._mapping or column_x_key[0]:
self._set_new_columns_from_json(list_of_dicts, column_x_key[1])
rows, new_hashes = self._build_rows(list_of_dicts)
self.table.insert_rows(rows, "end", emit_event=True)
self.remove_empty_rows()
self.adjust_column_texts()
self._define_checkboxes()
return new_hashes
def _build_rows(
self, list_of_dicts: List[Dict]
) -> Tuple[List[List[str]], Dict[int, str]]:
"""
Builds rows for the visual table from dictionaries,
using internal mapping and converting lists into comma-separated strings.
Hash dictionary keys start from current_total + 1.
Args:
list_of_dicts (List[Dict]): List of dictionaries representing each row.
Returns:
Tuple[List[List[str]], Dict[int, str]]:
- List of lists with the built rows.
- Dictionary mapping the row number (starting from current_total + 1) to its UUID.
"""
current_total = self.table.get_total_rows()
visible_headers = self.table.headers() or []
skip_checkbox = visible_headers and visible_headers[0] == self.DEFAULT_HEADER
column_order = visible_headers[1:] if skip_checkbox else visible_headers
column_mapping = {
key: pos + 1
for pos, col in enumerate(column_order)
for key in self._mapping
if self._mapping[key] == col
}
num_columns = len(column_order) + 1
rows = [""] * len(list_of_dicts)
new_hashes = {}
for i, item in enumerate(list_of_dicts):
row_idx = current_total + i
row = [""] * num_columns
for key, pos in column_mapping.items():
if key in item:
value = item[key]
if isinstance(value, list):
row[pos] = ", ".join(str(v) for v in value)
else:
row[pos] = str(value)
rows[i] = row
new_hash = str(uuid.uuid4())
self._row_hashes[row_idx] = new_hash
new_hashes[row_idx + 1] = new_hash
return rows, new_hashes
def _set_mapping_from_json(
self, list_of_dicts: List[Dict], clear_existing: bool = False
):
"""
Establishes a mapping between JSON keys by converting them to capitalized headers for the table.
Args:
list_of_dicts (List[Dict]): List of dictionaries containing the data.
clear_existing (bool): If True, clears the current mapping before setting a new one.
If False, only adds new keys.
"""
if not list_of_dicts:
return #
new_keys = list(list_of_dicts[0].keys())
if clear_existing or not self._mapping:
self._mapping = {k: k.capitalize() for k in new_keys}
else:
for k in new_keys:
if k not in self._mapping:
self._mapping[k] = k.capitalize()
def _set_new_columns_from_json(
self, list_of_dicts: List[Dict], clear_previous: bool = False
):
"""
Sets the columns of the tksheet table based on the received JSON and key mapping.
Args:
list_of_dicts (List[Dict]): List of dictionaries containing the data.
clear_previous (bool): If True, clears current columns before setting new ones.
If False, only adds missing columns.
"""
if not list_of_dicts:
return
# Ensure mapping is updated
self._set_mapping_from_json(
list_of_dicts, clear_existing=clear_previous
)
# New headers from mapping
new_headers = list(self._mapping.values())
if clear_previous:
self.table.headers(new_headers=new_headers)
else:
# Check which columns already exist
existing_columns = self.table.headers()
columns_to_add = [
col for col in new_headers if col not in existing_columns
]
if columns_to_add:
new_headers_combined = existing_columns + columns_to_add
self.table.headers(new_headers=new_headers_combined)
def _update_mapping_and_build_headers(
self, list_of_dicts: List[Dict]
) -> Tuple[List[str], List[str]]:
"""
Detects new keys in dictionaries, updates internal mapping, and
builds lists of keys (data access keys) and headers (column headers).
Args:
list_of_dicts (List[Dict]): List of dictionaries with data.
Returns:
Tuple[List[str], List[str]]:
- keys: ordered keys to be used for building rows (excluding "Selected").
- headers: visual headers, with "Selected" at the beginning.
"""
# 1) Detect all keys present in the dictionaries
detected_keys = set().union(*(d.keys() for d in list_of_dicts))
# 2) Add new keys that were not yet in the mapping
for key in sorted(detected_keys):
if key not in self._mapping:
self._mapping[key] = key.capitalize()
# 3) Exclude the special key (Selected) when building keys
keys = [
k
for k in self._mapping.keys()
if k.lower() != self.DEFAULT_HEADER.lower()
]
# 4) Build headers with "Selected" at the beginning
headers = [self.DEFAULT_HEADER] + [self._mapping[k] for k in keys]
return keys, headers
def adjust_column_text(
self, col_index: int, max_width: int = None
): # TODO: Separate into several methods
"""
Dynamically adjusts the text of each cell in the indicated column:
- Reconstructs original text by removing previous hyphens and line breaks.
- Inserts new line breaks and hyphens according to the current max_width.
- Adjusts row height and column width.
"""
font_tuple = self.table.font()
font_obj = font.Font(
family=font_tuple[0], size=font_tuple[1], weight=font_tuple[2]
)
total_rows = self.table.total_rows()
max_col_width = (
max_width
if max_width is not None
else self.MAX_COLUMN_WIDTH_DEFAULT
)
# The internal function _insert_breaks is the same one I provided,
# it is correct and needs no changes.
def _insert_breaks(text: str, current_font: font.Font, max_width_line: int) -> str:
# ... (The function from the previous answer goes here, unchanged)
words = text.split()
if not words:
return text
result_lines = []
current_line = ""
space_width = current_font.measure(" ")
MIN_FRAGMENT_LENGTH = 3
for word in words:
word_width = current_font.measure(word)
current_line_width = current_font.measure(current_line)
if (
not current_line
or current_line_width + space_width + word_width <= max_width_line
):
if current_line:
current_line += " " + word
else:
current_line = word
elif word_width <= max_width_line:
result_lines.append(current_line)
current_line = word
else:
if current_line:
result_lines.append(current_line)
current_line = ""
remaining_word = word
while current_font.measure(remaining_word) > max_width_line:
cut = 0
hyphen_width = current_font.measure("-")
for i in range(len(remaining_word)):
current_fragment = remaining_word[: i + 1]
if (
current_font.measure(current_fragment) + hyphen_width
> max_width_line
):
break
cut = i + 1
if cut < MIN_FRAGMENT_LENGTH:
for i in range(len(remaining_word)):
if (
current_font.measure(remaining_word[: i + 1])
> max_width_line
):
cut = i
break
cut = max(1, cut)
fragment = remaining_word[:cut]
result_lines.append(fragment + "-")
remaining_word = remaining_word[cut:]
current_line = remaining_word
if current_line:
result_lines.append(current_line)
return "\n".join(result_lines)
# --- START OF MODIFIED MAIN LOGIC ---
try:
current_col_width = self.table.column_width(col_index)
except TypeError:
current_col_width = self.table.column_width(col_index)
max_measured_col_width = 0
for row_index in range(total_rows):
cell_value = self.table.get_cell_data(row_index, col_index)
text_in_cell = str(cell_value) if cell_value is not None else ""
# --- THIS IS THE KEY NEW LOGIC! ---
# 1. Reconstruct words that were hyphenated ("word-\n" -> "word").
reconstructed_text = text_in_cell.replace("-\n", "")
# 2. Clear any other line breaks from a previous adjustment.
reconstructed_text = reconstructed_text.replace("\n", " ")
# --- END OF KEY LOGIC ---
# Ignore URLs as before
is_url = NavegadorServices.es_url(reconstructed_text)
if is_url:
# If we want URLs to also resize, we could continue,
# but for now we maintain the original logic.
if reconstructed_text != text_in_cell:
self.table.set_cell_data(row_index, col_index, reconstructed_text)
continue
reconstructed_text_width = font_obj.measure(reconstructed_text)
# Now, we decide whether to adjust or not based on the RECONSTRUCTED text
if reconstructed_text_width > max_col_width:
adjusted_text = _insert_breaks(reconstructed_text, font_obj, max_col_width)
else:
adjusted_text = reconstructed_text
# Update the cell only if the new format is different from what it already has
if adjusted_text != text_in_cell:
self.table.set_cell_data(
row_index, col_index, adjusted_text, redraw=False
)
lines = adjusted_text.count("\n") + 1
self.table.row_height(row_index, height=20 * lines)
# The rest of the column width calculation works the same
if "\n" in adjusted_text:
line_widths = [font_obj.measure(l) for l in adjusted_text.split("\n")]
width_in_use = max(line_widths) if line_widths else 0
else:
width_in_use = font_obj.measure(adjusted_text)
width_in_use = min(width_in_use, max_col_width)
if width_in_use > max_measured_col_width:
max_measured_col_width = width_in_use
# Logic to adjust column width at the end
# (Can be refined, but we maintain your original logic)
if max_measured_col_width > 0 and max_measured_col_width > current_col_width:
new_width = min(max_measured_col_width, max_col_width)
self.table.column_width(col_index, width=new_width)
def adjust_column_texts(self, max_width: int = None):
total_cols = self.table.total_columns()
for col_index in range(total_cols):
self.adjust_column_text(col_index, max_width)
def remove_empty_rows(self):
"""Removes completely empty rows (internal helper)."""
data = self.table.get_sheet_data(get_header=False, get_index=False)
empty_rows = [
i for i, row in enumerate(data) if all(cell in (None, "") for cell in row)
]
if empty_rows:
self.table.delete_rows(empty_rows, undo=False, emit_event=True)
def _define_checkboxes(self):
"""Applies or updates checkboxes in the first column."""
total = self.table.get_total_rows()
for i in range(total):
self.table.checkbox(
i,
0,
checked=False,
text="",
redraw=False,
check_function=lambda event_data: self.mark_checkboxes.emit(
len(self.get_marked_data_as_json())
),
)
self.table.redraw()
def _emit_header_double_click(self, event: Dict[str, Any]):
"""
Internal callback for header double-click. Translates the index to col_id
and emits global and specific signals associated with that column.
"""
col_idx = event.get("column")
headers = self.table.headers() or []
if col_idx is None or col_idx >= len(headers):
return
col_id = headers[col_idx]
# Global signal (for all columns)
if self._header_double_click_signals.get(self.ALL_HEADERS) is not None:
self._header_double_click_signals[self.ALL_HEADERS].emit()
# Specific column signal
if col_id in self._header_double_click_signals:
self._header_double_click_signals[col_id].emit()
def _emit_add_data(self, event_data):
"""
Emits the signal when rows/data are added to the table.
"""
if (
event_data.get("eventname") == "add_rows"
or event_data.get("eventname") == "insert_rows"
or event_data.get("eventname") == "delete_rows"
):
total = self.table.total_rows()
self.add_data.emit(total)
def sort_by_priority(
self,
col_id: str,
order_list: List[Any] = None,
ascending: Optional[bool] = None,
) -> None:
"""
Sorts table rows by the content of a column.
:param col_id: Internal or visible key of the column to sort.
:param order_list: Custom list for sorting. If None, type is detected.
:param ascending: True = force ascending, False = force descending, None = auto.
"""
headers = self.table.headers() or []
visible = self._mapping.get(col_id, col_id)
try:
col_idx = headers.index(visible)
except ValueError:
print(f"Header not found: {visible}")
return
data = self.table.get_sheet_data(get_header=False, get_index=False)
indices = list(range(len(data)))
col_data = [row[col_idx] for row in data]
if not col_data:
return
def is_number(x):
try:
float(x)
return True
except (ValueError, TypeError):
return False
def detect_order(values: List[Any]) -> str:
asc = sorted(values)
desc = list(reversed(asc))
if values == asc:
return "asc"
elif values == desc:
return "desc"
return "none"
if order_list is None:
if all(is_number(x) for x in col_data if x not in ("", None)):
convert = lambda x: float(x)
else:
convert = lambda x: str(x).lower()
converted_values = [convert(x) for x in col_data if x not in ("", None)]
current_order = detect_order(converted_values)
reverse = (
not ascending if ascending is not None else current_order == "asc"
)
key_fn = lambda i: (
data[i][col_idx] in ("", None),
(
convert(data[i][col_idx])
if data[i][col_idx] not in ("", None)
else None
),
)
else:
mapping = {val: idx for idx, val in enumerate(order_list)}
default = len(order_list)
converted_values = [mapping.get(x, default) for x in col_data]
current_order = detect_order(converted_values)
reverse = (
not ascending if ascending is not None else current_order == "asc"
)
key_fn = lambda i: (
data[i][col_idx] in ("", None),
mapping.get(data[i][col_idx], default),
)
new_order = sorted(indices, key=key_fn, reverse=reverse)
new_hashes = [self._row_hashes[i] for i in new_order]
for new_idx, old_idx in enumerate(new_order):
self._row_hashes[new_idx] = new_hashes[new_idx]
self.table.move_rows(
move_to=0,
to_move=new_order,
create_selections=False,
emit_event=False,
undo=False,
)
self.table.selection_clear()
self.table.redraw()
def _set_value_in_filtered_rows(
self,
row_filter: callable, # function (row_idx, row) -> bool
new_value: str,
target_column: str,
first_match_only: bool = True,
) -> bool:
"""
Base function to set value in rows that meet a filter.
"""
current_visible_headers = self.table.headers() or []
if not current_visible_headers:
return False
target_col_name_for_index = self._mapping.get(
target_column, target_column
)
if target_col_name_for_index not in current_visible_headers:
err_msg = f"Target column '{target_col_name_for_index}' "
if target_column != target_col_name_for_index:
err_msg += f"(resolved from key '{target_column}') "
err_msg += (
f"does not exist in the table's visible headers. "
f"Current headers: {current_visible_headers}. "
f"Current mapping (_mapping): {self._mapping}"
)
raise ValueError(err_msg)
target_col_idx = current_visible_headers.index(
target_col_name_for_index
)
sheet_data = self.table.get_sheet_data(get_header=False, get_index=False)
if not sheet_data:
return False
modifications_made = False
for row_idx, row in enumerate(sheet_data):
if target_col_idx >= len(row):
continue
if row_filter(row_idx, row):
self.table.set_cell_data(
row_idx, target_col_idx, str(new_value), redraw=False
)
modifications_made = True
if first_match_only:
break
if modifications_made:
self.table.redraw()
if target_col_name_for_index == self.DEFAULT_HEADER:
self.mark_checkboxes.emit(
len(self.get_marked_data_as_json())
)
return modifications_made
def set_value_in_row(
self,
search_data: str,
search_column: str,
new_value: Union[str, int, float, bool],
target_column: str,
) -> bool:
"""
Sets a new_value in the target_column of a row that matches search_data
in the search_column.
:param search_data: Value to search for.
:param search_column: Column name (internal key or visible header) to search in.
:param new_value: New value to set.
:param target_column: Column name (internal key or visible header) where to set the new value.
:return: True if the value was set, False otherwise.
"""
search_col_name_for_index = self._mapping.get(
search_column, search_column
)
current_visible_headers = self.table.headers() or []
if search_col_name_for_index not in current_visible_headers:
print(f"Search column '{search_col_name_for_index}' not found in visible headers.")
return False
search_col_idx = current_visible_headers.index(search_col_name_for_index)
def row_matches_filter(row_idx, row):
return row[search_col_idx] == search_data
return self._set_value_in_filtered_rows(
row_matches_filter, new_value, target_column, first_match_only=True
)
def set_value_in_rows_by_hash(
self,
row_hashes: Union[str, List[str]],
new_value: Union[str, int, float, bool],
target_column: str,
) -> bool:
"""
Sets a new_value in the target_column for rows identified by their hashes.
:param row_hashes: Single hash (str) or list of hashes (List[str]) of the rows to modify.
:param new_value: New value to set.
:param target_column: Column name (internal key or visible header) where to set the new value.
:return: True if any value was set, False otherwise.
"""
if isinstance(row_hashes, str):
hashes_to_match = {row_hashes}
else:
hashes_to_match = set(row_hashes)
# Invert _row_hashes for efficient lookup
hash_to_row_idx = {v: k for k, v in self._row_hashes.items()}
def hash_matches_filter(row_idx, row):
row_hash = self._row_hashes.get(row_idx)
return row_hash in hashes_to_match
return self._set_value_in_filtered_rows(
hash_matches_filter, new_value, target_column, first_match_only=False
)
def clear_all_data(self):
"""Clears all data from the table but keeps headers."""
self.table.set_sheet_data(data=[["" for _ in self.table.headers()]], redraw=True)
self.table.set_total_rows(0, redraw=True)
self._row_hashes.clear()
self.table.clear_selections()
self.table.reset_row_heights()
self.table.redraw()
def delete_all_rows(self):
"""Deletes all rows and clears internal hashes."""
if self.table.total_rows() > 0:
self.table.delete_rows(
rows=list(range(self.table.total_rows())),
undo=False,
emit_event=True,
)
self._row_hashes.clear()
def get_row_data(self, row_idx: int) -> Optional[Dict[str, Any]]:
"""
Retrieves data for a specific row by its index.
:param row_idx: The index of the row to retrieve.
:return: A dictionary of column_name: value for the row, or None if the index is invalid.
"""
total_rows = self.table.total_rows()
if not (0 <= row_idx < total_rows):
return None
row_data = self.table.get_row_data(row_idx)
headers = self.table.headers() or []
if not headers or not row_data:
return None
# Convert list of row data to a dictionary using headers
row_dict = {}
for i, header in enumerate(headers):
# Try to map back to the original internal key if possible
internal_key = self._inverse_mapping.get(header, header)
row_dict[internal_key] = row_data[i]
return row_dict
def get_selected_rows_data(self) -> List[Dict[str, Any]]:
"""
Retrieves the data for all currently selected rows as a list of dictionaries.
:return: A list of dictionaries, where each dictionary represents a selected row.
Returns an empty list if no rows are selected or if the table is empty.
"""
selected_rows_indices = sorted(self.table.get_selected_rows())
selected_cells_indices = {cell[0] for cell in self.table.get_selected_cells()}
all_selected_indices = sorted(list(selected_rows_indices | selected_cells_indices))
if not all_selected_indices:
return []
selected_data = []
for row_idx in all_selected_indices:
row_dict = self.get_row_data(row_idx)
if row_dict:
selected_data.append(row_dict)
return selected_data
def get_marked_data_as_json(self) -> List[Dict[str, Any]]:
"""
Retrieves data from rows where the "Selected" checkbox is marked (True).
Returns:
List[Dict[str, Any]]: A list of dictionaries, where each dictionary
represents a row with the "Selected" checkbox marked.
The "Selected" key itself is excluded from the output dictionaries.
Returns an empty list if no rows are marked.
"""
marked_data = []
headers = self.table.headers() or []
try:
selected_col_idx = headers.index(self.DEFAULT_HEADER)
except ValueError:
# "Selected" column not found, return empty list
return []
for row_idx in range(self.table.total_rows()):
# Get the cell data directly for the checkbox column
checkbox_value = self.table.get_cell_data(row_idx, selected_col_idx)
# tksheet checkbox values are 0 for unchecked, 1 for checked
if checkbox_value == 1: # Check if the checkbox is marked
row_dict = self.get_row_data(row_idx)
if row_dict and self.DEFAULT_HEADER.lower() in row_dict:
# Remove the "Selected" key from the output dictionary
del row_dict[self.DEFAULT_HEADER.lower()]
marked_data.append(row_dict)
return marked_data
def get_selected_data_as_json(self) -> List[Dict[str, Any]]:
"""
Retrieves data for selected rows, excluding the "Selected" column.
Returns:
List[Dict[str, Any]]: A list of dictionaries, each representing a selected row.
The "Selected" key is excluded from the output dictionaries.
Returns an empty list if no rows are selected.
"""
selected_rows_data = self.get_selected_rows_data()
cleaned_data = []
for row_dict in selected_rows_data:
if self.DEFAULT_HEADER.lower() in row_dict:
# Create a new dictionary without the "selected" key
cleaned_row = {
k: v
for k, v in row_dict.items()
if k.lower() != self.DEFAULT_HEADER.lower()
}
cleaned_data.append(cleaned_row)
else:
cleaned_data.append(row_dict)
return cleaned_data
def get_all_data_as_json(self) -> List[Dict[str, Any]]:
"""
Retrieves all data from the table as a list of dictionaries,
excluding the "Selected" column.
Returns:
List[Dict[str, Any]]: A list of dictionaries, where each dictionary
represents a row in the table. The "Selected" key
is excluded from the output dictionaries.
Returns an empty list if the table is empty.
"""
all_data = []
headers = self.table.headers() or []
if not headers:
return []
# Determine the index of the "Selected" column, if it exists
selected_col_idx = -1
try:
selected_col_idx = headers.index(self.DEFAULT_HEADER)
except ValueError:
pass # "Selected" column does not exist
for row_idx in range(self.table.total_rows()):
row_data = self.table.get_row_data(row_idx)
row_dict = {}
for col_idx, header in enumerate(headers):
if col_idx == selected_col_idx:
continue # Skip the "Selected" column
# Try to map back to the original internal key if possible
internal_key = self._inverse_mapping.get(header, header)
row_dict[internal_key] = row_data[col_idx]
all_data.append(row_dict)
return all_data
def registrar_slot_para_dobleclick_cabecera(
self, col_id: str, slot: Callable[..., Any]
):
"""
Registers a slot (function/method) to be called when a column header
is double-clicked.
:param col_id: The identifier of the column. Can be the visible header name
or the internal key if a mapping is defined.
Use self.ALL_HEADERS to register for any header double-click.
:param slot: The callable to be executed when the event occurs.
"""
normalized_col_id = col_id.lower()
if normalized_col_id == self.ALL_HEADERS:
self._senales_dobleclick_cabecera[self.ALL_HEADERS] = Señal()
self._senales_dobleclick_cabecera[self.ALL_HEADERS].conectar(slot)
else:
# Check if the col_id is an internal key and resolve to visible header
# Or if it's already a visible header
visible_header = self._mapping.get(col_id, col_id)
if visible_header not in self._senales_dobleclick_cabecera:
self._senales_dobleclick_cabecera[visible_header] = Señal()
self._senales_dobleclick_cabecera[visible_header].conectar(slot)
def activar_evento_entrar_celda(self):
"""
Activates the cell enter event (when mouse enters a cell).
This event emits the `enter_cell` signal with (row, column, value) as arguments.
"""
self.table.extra_bindings(
"cell_enter",
func=lambda event_data: self.entrar_celda.emit(
event_data["row"],
event_data["column"],
self.table.get_cell_data(
event_data["row"], event_data["column"]
),
),
)
def activar_evento_cambios_en_celda(self):
"""
Activates the cell change event (when a cell's content is modified).
This event emits the `edit_cell` signal with (row, column, new_value) as arguments.
"""
self.table.extra_bindings(
"end_edit_cell",
func=lambda event_data: self.edit_cell.emit(
event_data["row"], event_data["column"], event_data["value"]
),
)
I hope these ideas and my humble implementations can serve as a starting point for improving and expanding the library's functionality.