Skip to content

Things that could maybe be added #307

@IgnacioAusili

Description

@IgnacioAusili

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Useful info/extensionSomething that might be useful for the community

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions