Source code for oci_policy_analysis.presentation.desktop.data_table

##########################################################################
# Copyright (c) 2024, Oracle and/or its affiliates.
# Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/
#
# DISCLAIMER This is not an official Oracle application, It does not supported by Oracle Support.
#
# data_table.py
#
# @author: Andrew Gregory
#
# Supports Python 3.12 and above
#
# coding: utf-8
##########################################################################
import time
import tkinter as tk
import tkinter.font as tkfont
from collections.abc import Callable
from tkinter import ttk
from typing import Any

from oci_policy_analysis.application.core.support.logger import get_logger

logger = get_logger(component='data_table')


[docs] class DataTable(ttk.Frame): """A Tkinter table widget with alternating row colors, sortable columns, resizable columns, show/hide columns, row selection with callback, full space utilization, cell copy functionality, and row context menu. Note: for checklist-style tables with checkboxes and custom action button, see the more generic CheckboxTable class also defined below. Note: ttk.Treeview does not natively support row-specific heights. This widget adjusts the whole table's row height to fit the largest visible explicit newline count in the current row set, capped to keep tables usable. Text does not wrap automatically. Attributes: parent: The parent Tkinter widget. columns: List of all possible column names. display_columns: List of initially displayed column names. data: List of dictionaries containing row data. sortable: Enable/disable column sorting (default: True). row_colors: Tuple of colors for alternating rows (default: white, light gray). selection_callback: Optional function to call with selected rows (default: None). Can be omitted if no callback is needed. multi_select: Enable/disable multi-row selection (default: False). column_widths: Dictionary mapping column names to initial widths (default: None, uses 100 for all columns). row_context_menu_callback: Optional function to create a context menu for a row (default: None). """ def __init__( self, parent: tk.Widget, columns: list[str], display_columns: list[str], data: list[dict], sortable: bool = True, row_colors: tuple[str, str] = ('#FFFFFF', '#F0F0F0'), selection_callback: Callable[[list[dict]], None] | None = None, multi_select: bool = False, column_widths: dict[str, int] | None = None, highlights: list[tuple[str, Any, str]] | None = None, row_context_menu_callback: Callable[[int], tk.Menu] | None = None, height: int | None = None, initial_sort_column: str | None = None, initial_sort_descending: bool = False, ) -> None: super().__init__(parent) self._height = height logger.debug('Initializing DataTable with %d columns and %d rows', len(columns), len(data)) self.all_columns = columns self.display_columns = display_columns self.data = data self.sortable = sortable self.row_colors = row_colors self.sort_directions: dict[str, bool] = {col: False for col in columns} self.last_sorted_column: str | None = None self.last_sort_descending: bool = False self.hidden_columns = set(columns) - set(display_columns) self.column_widths: dict[str, int] = ( column_widths if column_widths is not None else {col: 100 for col in columns} ) self.highlight_rules = highlights or [] self.min_width = 50 self.max_width = 300 self.resizing_column: str | None = None self.selection_callback = selection_callback self.multi_select = multi_select self.row_context_menu_callback = row_context_menu_callback self.data_map: dict[str, int] = {} self.selected_cell: tuple[str, int] | None = None # (column, row_index) self.max_auto_row_lines = 6 self._tree_style_name = f'DataTable{id(self)}.Treeview' # Configure frame to expand in grid layout self.grid_rowconfigure(0, weight=1) self.grid_columnconfigure(0, weight=1) # Validate display_columns and column_widths if not set(display_columns).issubset(columns): logger.error('Invalid display_columns: %s', display_columns) raise ValueError('All display_columns must be in columns') if column_widths is not None and not set(column_widths.keys()).issubset(columns): logger.error('Invalid column_widths keys: %s', column_widths.keys()) raise ValueError('All column_widths keys must be in columns') self._setup_ui() if initial_sort_column and initial_sort_column in self.all_columns: self.last_sorted_column = initial_sort_column self.last_sort_descending = initial_sort_descending self.sort_directions[initial_sort_column] = initial_sort_descending self._apply_sort() logger.debug('UI setup completed') def _setup_ui(self) -> None: """Set up the table UI with headers, data rows, and selection handling.""" # Main frame for table self.table_frame = tk.Frame(self) self.table_frame.grid(row=0, column=0, sticky='nsew', padx=5, pady=5) self.table_frame.grid_rowconfigure(0, weight=1) self.table_frame.grid_columnconfigure(0, weight=1) # Configure Treeview self._style = ttk.Style(self) self._style.configure(self._tree_style_name, padding=(0, 0, 8, 0)) treeview_args = { 'master': self.table_frame, 'columns': self.display_columns, 'show': 'headings', 'selectmode': 'extended' if self.multi_select else 'browse', 'style': self._tree_style_name, } if self._height is not None: treeview_args['height'] = self._height self.tree = ttk.Treeview(**treeview_args) self.tree.grid(row=0, column=0, sticky='nsew') logger.debug('Treeview configured with %d columns', len(self.display_columns)) # Configure scrollbars vsb = ttk.Scrollbar(self.table_frame, orient='vertical', command=self.tree.yview) vsb.grid(row=0, column=1, sticky='ns') self.tree.configure(yscrollcommand=vsb.set) hsb = ttk.Scrollbar(self.table_frame, orient='horizontal', command=self.tree.xview) hsb.grid(row=1, column=0, sticky='ew') self.tree.configure(xscrollcommand=hsb.set) # Configure column headers for col in self.display_columns: self.tree.heading(col, text=col, anchor='w', command=lambda c=col: self._sort_column(c)) self.tree.column(col, anchor='w', width=self.column_widths[col], minwidth=self.min_width, stretch=False) # Configure alternating row colors self.tree.tag_configure('oddrow', background=self.row_colors[1]) self.tree.tag_configure('evenrow', background=self.row_colors[0]) # Populate data self._populate_data() # Bind events self.tree.bind('<ButtonPress-1>', self._start_resize) self.tree.bind('<B1-Motion>', self._resize_column) self.tree.bind('<ButtonRelease-1>', self._end_resize) self.tree.bind('<<TreeviewSelect>>', self._on_selection) self.tree.bind('<Button-1>', self._select_cell) self.tree.bind('<Control-c>', self._copy_cell) # Ctrl+C for Windows/Linux self.tree.bind('<Command-c>', self._copy_cell) # Command+C for macOS self.tree.bind('<Button-2>', self._show_context_menu) # Middle-click for macOS self.tree.bind('<Button-3>', self._show_context_menu) # Initial column update self._update_columns() def _populate_data(self) -> None: """Populate the table with data, applying alternating row colors and optional highlight rules.""" t0 = time.perf_counter() logger.info('DataTable._populate_data START, %d rows', len(self.data)) self._update_dynamic_row_height() # Clear existing for item in self.tree.get_children(): self.tree.delete(item) self.data_map.clear() # Populate new rows for i, row in enumerate(self.data): # Start with no tags tags = [] # Apply highlight rules for col, expected_value, color in getattr(self, 'highlight_rules', []): if row.get(col) == expected_value: logger.info('Highlighting row %d, column %s for value %s', i, col, expected_value) tag_name = f'highlight_{col}_{expected_value}' # Only configure the tag once if not self.tree.tag_has(tag_name): self.tree.tag_configure(tag_name, background=color) tags.append(tag_name) # Normal alternating row color if len(tags) == 0: tags.append('evenrow' if i % 2 == 0 else 'oddrow') values = [row.get(col, '') for col in self.tree['columns']] item_id = self.tree.insert('', 'end', values=values, tags=tags) self.data_map[item_id] = i elapsed = time.perf_counter() - t0 logger.info('DataTable._populate_data END, %d rows, elapsed: %.3fs', len(self.data), elapsed) def _update_dynamic_row_height(self) -> None: """Adjust this table's row height for explicit multiline cell values.""" visible_columns = list(self.tree['columns']) if hasattr(self, 'tree') else list(self.display_columns) max_lines = 1 for row in self.data: for col in visible_columns: value = row.get(col, '') if value is None: continue line_count = str(value).count('\n') + 1 if line_count > max_lines: max_lines = line_count if max_lines >= self.max_auto_row_lines: break if max_lines >= self.max_auto_row_lines: break max_lines = max(1, min(max_lines, self.max_auto_row_lines)) line_height = self._get_treeview_line_height() row_height = max(20, (line_height * max_lines) + 8) self._style.configure(self._tree_style_name, rowheight=row_height) logger.debug( 'DataTable rowheight set to %d for %d visible line(s)', row_height, max_lines, ) def _get_treeview_line_height(self) -> int: """Return the current line height for the table font.""" font_spec = self._style.lookup(self._tree_style_name, 'font') or self._style.lookup('Treeview', 'font') try: if font_spec: return int(tkfont.Font(root=self, font=font_spec).metrics('linespace')) return int(tkfont.nametofont('TkDefaultFont').metrics('linespace')) except Exception: return 16 def _apply_sort(self) -> None: """Apply current sort (last_sorted_column / last_sort_descending) to self.data and refresh display.""" col = self.last_sorted_column if not col or col not in self.all_columns or not self.data: self._populate_data() return reverse = self.last_sort_descending try: self.data.sort(key=lambda x: x.get(col, ''), reverse=reverse) except (ValueError, TypeError): self.data.sort(key=lambda x: str(x.get(col, '')), reverse=reverse) for c in self.tree['columns']: indicator = ' ▼' if (c == col and reverse) else (' ▲' if (c == col and not reverse) else '') self.tree.heading(c, text=f'{c}{indicator}', anchor='w', command=lambda c=c: self._sort_column(c)) self._populate_data() def _sort_column(self, col: str) -> None: """Sort the table by the specified column and update indicators.""" if not self.sortable or col not in self.tree['columns']: logger.debug('Sorting skipped: column %s not sortable or not visible', col) return logger.debug('Sorting column %s', col) self.sort_directions[col] = not self.sort_directions[col] reverse = self.sort_directions[col] self.last_sorted_column = col self.last_sort_descending = reverse try: self.data.sort(key=lambda x: x.get(col, ''), reverse=reverse) except (ValueError, TypeError): self.data.sort(key=lambda x: str(x.get(col, '')), reverse=reverse) for c in self.tree['columns']: self.tree.heading(c, text=c, anchor='w') indicator = ' ▼' if reverse else ' ▲' self.tree.heading(col, text=f'{col}{indicator}', anchor='w') self._populate_data() logger.info('Table sorted by %s (%s)', col, 'descending' if reverse else 'ascending') def _start_resize(self, event: tk.Event) -> str | None: """Start column resizing.""" if self.tree.identify_region(event.x, event.y) != 'separator': return None col = self.tree.identify_column(event.x) col_index = int(col.replace('#', '')) - 1 if col_index >= len(self.tree['columns']): logger.debug('Invalid column index for resize: %d', col_index) return None self.resizing_column = self.tree['columns'][col_index] self.resize_start_x = event.x_root logger.debug('Starting resize for column %s', self.resizing_column) return 'break' def _resize_column(self, event: tk.Event) -> None: """Handle column resizing without affecting other columns.""" if not self.resizing_column: return delta = event.x_root - self.resize_start_x new_width = max(self.min_width, min(self.max_width, self.column_widths[self.resizing_column] + delta)) self.column_widths[self.resizing_column] = new_width self.tree.column(self.resizing_column, width=new_width, stretch=False) self.resize_start_x = event.x_root logger.debug('Resizing column %s to width %d', self.resizing_column, new_width) def _end_resize(self, event: tk.Event) -> None: """End column resizing.""" if self.resizing_column: logger.info( 'Finished resizing column %s to width %d', self.resizing_column, self.column_widths[self.resizing_column], ) self.resizing_column = None def _show_context_menu(self, event: tk.Event) -> None: """Show context menu for column show/hide or row actions.""" region = self.tree.identify_region(event.x, event.y) if region == 'heading': col = self.tree.identify_column(event.x) if not col: return col_index = int(col.replace('#', '')) - 1 if col_index >= len(self.tree['columns']): return self.selected_column = self.tree['columns'][col_index] self.context_menu.entryconfigure( 'Hide Column', state='normal' if self.selected_column not in self.hidden_columns else 'disabled' ) self.context_menu.post(event.x_root, event.y_root) logger.debug('Showing column context menu for %s', self.selected_column) elif region == 'cell' and self.row_context_menu_callback: item = self.tree.identify_row(event.y) if item and item in self.data_map: row_index = self.data_map[item] menu = self.row_context_menu_callback(row_index) if menu: menu.post(event.x_root, event.y_root) logger.debug('Showing row context menu for row %d', row_index) def _select_cell(self, event: tk.Event) -> None: """Select a cell for copying.""" region = self.tree.identify_region(event.x, event.y) if region != 'cell': self.selected_cell = None return item = self.tree.identify_row(event.y) col = self.tree.identify_column(event.x) if item and col: col_index = int(col.replace('#', '')) - 1 if col_index < len(self.tree['columns']): self.selected_cell = (self.tree['columns'][col_index], self.data_map.get(item, -1)) logger.debug('Selected cell: column=%s, row_index=%d', *self.selected_cell) def _copy_cell(self, event: tk.Event) -> str | None: """Copy the selected cell's content to the clipboard.""" if not self.selected_cell or self.selected_cell[1] == -1: logger.debug('No cell selected for copying') return None col, row_index = self.selected_cell value = str(self.data[row_index].get(col, '')) self.clipboard_clear() self.clipboard_append(value) logger.info('Copied cell value: %s', value) return 'break' def _hide_column(self) -> None: """Hide the selected column.""" if hasattr(self, 'selected_column') and self.selected_column not in self.hidden_columns: self.hidden_columns.add(self.selected_column) self._update_columns() logger.info('Hid column %s', self.selected_column) def _show_column(self, col: str) -> None: """Show a previously hidden column.""" if col in self.hidden_columns: self.hidden_columns.remove(col) self._update_columns() logger.info('Showed column %s', col) def _update_columns(self) -> None: """Update displayed columns in the Treeview.""" visible_columns = [col for col in self.all_columns if col not in self.hidden_columns] self.tree.configure(columns=visible_columns) sorted_col = next((col for col, reverse in self.sort_directions.items() if reverse), None) for col in visible_columns: indicator = ' ▼' if sorted_col == col and self.sort_directions[col] else '' self.tree.heading(col, text=f'{col}{indicator}', anchor='w', command=lambda c=col: self._sort_column(c)) self.tree.column(col, anchor='w', width=self.column_widths[col], stretch=False) self._populate_data() logger.debug('Updated columns: %d visible', len(visible_columns))
[docs] def set_multi_select(self, multi_select: bool) -> None: """Set single or multi-select mode.""" self.multi_select = multi_select self.tree.configure(selectmode='extended' if multi_select else 'browse') logger.info('Selection mode set to %s', 'multi-select' if multi_select else 'single-select')
[docs] def set_display_columns(self, display_columns: list[str]) -> None: """Set the displayed columns externally.""" if not set(display_columns).issubset(self.all_columns): logger.error('Invalid display_columns: %s', display_columns) raise ValueError('All display_columns must be in columns') self.display_columns = display_columns self.hidden_columns = set(self.all_columns) - set(display_columns) self._update_columns() logger.debug('Set display columns: %s', display_columns)
def _on_selection(self, event: tk.Event) -> None: """Handle row selection and trigger callback.""" if not self.selection_callback: return selected_items = self.tree.selection() selected_rows = [self.data[self.data_map[item]] for item in selected_items if item in self.data_map] self.selection_callback(selected_rows) logger.debug('Selected %d rows', len(selected_rows))
[docs] def update_data(self, new_data: list[dict]) -> None: """Update table data and refresh display. Re-applies current sort if one was set.""" t0 = time.perf_counter() logger.info('DataTable.update_data START, %d rows', len(new_data)) self.data = new_data if self.last_sorted_column and self.last_sorted_column in self.all_columns: self._apply_sort() else: self._populate_data() elapsed = time.perf_counter() - t0 logger.info('DataTable.update_data END, %d rows, elapsed: %.3fs', len(new_data), elapsed)
[docs] def apply_theme(self, theme: str) -> None: """ Apply light or dark theme colors to the Treeview. Args: theme: 'light' or 'dark' """ if theme == 'dark': bg = '#2b2b2b' fg = '#f0f0f0' row_colors = ('#2f2f2f', '#333333') selected_bg = '#444444' selected_fg = '#ffffff' else: bg = '#ffffff' fg = '#000000' row_colors = ('#ffffff', '#f7f7f7') selected_bg = '#e0e0e0' selected_fg = '#000000' style = ttk.Style(self) style.configure( self._tree_style_name, background=bg, fieldbackground=bg, foreground=fg, bordercolor=bg, borderwidth=1, padding=(0, 0, 8, 0), ) style.map( self._tree_style_name, background=[('selected', selected_bg)], foreground=[('selected', selected_fg)], ) self._style = style self._update_dynamic_row_height() # Update alternating rows dynamically self.row_colors = row_colors self.tree.tag_configure('evenrow', background=row_colors[0]) self.tree.tag_configure('oddrow', background=row_colors[1]) logger.info('Applied %s theme to DataTable', theme)
# ------------------------------------------------------------------------------
[docs] class CheckboxTable(ttk.Frame): """ A DataTable-based Tkinter widget for a table with a first-column checkbox and resizable columns, alternating backgrounds, and an action button. Optional display_columns restricts which columns are shown (subset of columns; the checkbox column is "☑"). Optional sortable enables column header sorting on the inner DataTable (default False). """ def __init__( self, parent, columns, data, action_buttons=None, display_columns=None, sortable=False, enable_select_all=True, checked_by_default=True, max_height=None, column_widths=None, geometry_manager='pack', check_changed_callback=None, # Callback for any checkbox state change select_all_callback=None, # NEW: callback when select-all or select-none is triggered row_context_menu_callback=None, # NEW: callback for per-row right-click ): super().__init__(parent) self.base_columns = columns self.columns = ['☑'] + columns if display_columns is None: self._display_columns = self.columns else: if not set(display_columns).issubset(self.columns): raise ValueError('All display_columns must be in columns (checkbox column is "☑")') self._display_columns = list(display_columns) self.sortable = sortable if action_buttons is not None and len(action_buttons) > 0: self._action_buttons = list(action_buttons) else: self._action_buttons = [] # No buttons when not passed or empty; no default button self.enable_select_all = enable_select_all self.max_height = max_height self.checked_by_default = checked_by_default self.column_widths = {'☑': 34, **(column_widths or {col: 160 for col in columns})} self._geometry_manager = geometry_manager self.check_changed_callback = check_changed_callback self.select_all_callback = select_all_callback # NEW self.row_context_menu_callback = row_context_menu_callback # NEW self._prepare_data(data) self._build_ui() def _prepare_data(self, data): t0 = time.perf_counter() logger.info('CheckboxTable._prepare_data START, %d rows', len(data)) # Optimization: Cache BooleanVars for unchanged rows by ID or index # (For best results, rows should have a unique 'Internal ID' or similar) if not hasattr(self, '_var_cache') or not isinstance(self._var_cache, dict): self._var_cache = {} new_var_cache = {} self.data = [] self.check_vars = [] for idx, row in enumerate(data or []): # Use 'Internal ID' if present as row key, otherwise fallback to index row_id = row.get('Internal ID', idx) checked_state = row.get('checked', self.checked_by_default) if row_id in self._var_cache: var = self._var_cache[row_id] var.set(checked_state) # update state in case data changed else: var = tk.BooleanVar(master=self, value=checked_state) new_var_cache[row_id] = var r = dict(row) r['☑'] = var self.data.append(r) self.check_vars.append(var) self._var_cache = new_var_cache # update cache, old vars for missing rows will be GC'd elapsed = time.perf_counter() - t0 logger.info('CheckboxTable._prepare_data END, %d rows, elapsed: %.3fs', len(data), elapsed) def _render_table_data(self): # Convert per-row BooleanVar to unicode for display rendered = [] for row in self.data: r = dict(row) r['☑'] = '☑' if row['☑'].get() else '☐' rendered.append(r) return rendered def _rebuild_table(self): t0 = time.perf_counter() logger.info('CheckboxTable._rebuild_table START, %d rows', len(self.data)) # Redraw DataTable with current check states rendered_data = self._render_table_data() self.data_table.update_data(rendered_data) elapsed = time.perf_counter() - t0 logger.info('CheckboxTable._rebuild_table END, %d rows, elapsed: %.3fs', len(self.data), elapsed) def _toggle_check_row(self, event): # Toggle checked state on checkbox column click item_id = self.data_table.tree.identify_row(event.y) col = self.data_table.tree.identify_column(event.x) if not item_id or col != '#1': return row_idx = self.data_table.data_map.get(item_id, None) if row_idx is None: return var = self.check_vars[row_idx] var.set(not var.get()) self._rebuild_table() self._update_select_all_label() if self.check_changed_callback: self.check_changed_callback(self.get_checked_rows()) def _toggle_select_all(self): currently_all = all(var.get() for var in self.check_vars) if self.select_all_callback: # Provide visible row Internal IDs and intended state to parent visible_ids = [] for _idx, row in enumerate(self.data): if 'Internal ID' in row and row['Internal ID']: visible_ids.append(row['Internal ID']) self.select_all_callback(visible_ids, not currently_all) # parent will trigger table update reflecting new selection else: for var in self.check_vars: var.set(not currently_all) self._rebuild_table() self._update_select_all_label() if self.check_changed_callback: self.check_changed_callback(self.get_checked_rows()) def _update_select_all_label(self): if hasattr(self, 'select_all_btn'): if all(var.get() for var in self.check_vars) and self.check_vars: self.select_all_btn.config(text='Select None') else: self.select_all_btn.config(text='Select All') self._update_row_count_label() def _update_row_count_label(self): """Refresh the Rows (Total / Shown / Selected) label in the button bar.""" if not hasattr(self, '_rows_label'): return total = len(self.data) selected = sum(1 for v in self.check_vars if v.get()) self._rows_label.config(text=f'Rows Shown / Selected ({total} / {selected})')
[docs] def get_checked_rows(self): checked = [] for idx, var in enumerate(self.check_vars): if var.get(): row = dict(self.data[idx]) del row['☑'] checked.append(row) return checked
[docs] def update_data(self, data): t0 = time.perf_counter() logger.info('CheckboxTable.update_data START, %d rows', len(data)) self._prepare_data(data) self._rebuild_table() self._update_select_all_label() elapsed = time.perf_counter() - t0 logger.info('CheckboxTable.update_data END, %d rows, elapsed: %.3fs', len(data), elapsed)
def _build_ui(self): # DataTable instantiation self.data_table = DataTable( self, columns=self.columns, display_columns=self._display_columns, data=self._render_table_data(), sortable=self.sortable, row_colors=('#FFFFFF', '#F7F7F7'), multi_select=False, column_widths=self.column_widths, row_context_menu_callback=self.row_context_menu_callback, # NEW ) if self.max_height: if self._geometry_manager == 'grid': self.data_table.grid(row=0, column=0, sticky='nsew', padx=2, pady=2) self.grid_rowconfigure(0, weight=1, minsize=self.max_height) self.grid_columnconfigure(0, weight=1) else: self.data_table.pack(fill='both', expand=True, padx=2, pady=(2, 0)) else: self.data_table.pack(fill='both', expand=True, padx=2, pady=(2, 0)) # Bind click to toggle check state on first column self.data_table.tree.bind('<Button-1>', self._toggle_check_row) # Button bar: use ttk.Frame so background matches app theme (no distinct bar color) btn_row = ttk.Frame(self) self.action_btns = [] if self._geometry_manager == 'grid': btn_row.grid(row=2, column=0, sticky='ew', padx=8, pady=(4, 7)) if self.enable_select_all: self.select_all_btn = ttk.Button(btn_row, text='Select All', width=11, command=self._toggle_select_all) self.select_all_btn.grid(row=0, column=0, padx=(0, 8), sticky='w') col_offset = 1 else: col_offset = 0 for text, cb in self._action_buttons: cmd = (lambda c=cb: lambda: c(self.get_checked_rows()))(cb) btn = ttk.Button(btn_row, text=text, command=cmd) btn.grid(row=0, column=col_offset, padx=(0, 8), sticky='w') col_offset += 1 self.action_btns.append(btn) self._rows_label = ttk.Label(btn_row, text='Rows (0 / 0 / 0)') self._rows_label.grid(row=0, column=col_offset, padx=(16, 0), sticky='w') else: btn_row.pack(fill='x', padx=8, pady=(4, 7)) if self.enable_select_all: self.select_all_btn = ttk.Button(btn_row, text='Select All', width=11, command=self._toggle_select_all) self.select_all_btn.pack(side='left', padx=(0, 8)) for text, cb in self._action_buttons: cmd = (lambda c=cb: lambda: c(self.get_checked_rows()))(cb) btn = ttk.Button(btn_row, text=text, command=cmd) btn.pack(side='left', padx=(0, 8)) self.action_btns.append(btn) self._rows_label = ttk.Label(btn_row, text='Rows (0 / 0 / 0)') self._rows_label.pack(side='left', padx=(16, 0)) if self.action_btns: self.action_btn = self.action_btns[0] self._update_select_all_label()