Source code for oci_policy_analysis.ui.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
from collections.abc import Callable
from tkinter import ttk
from typing import Any

from oci_policy_analysis.common.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 multi-line text wrapping. Text with newlines may appear clipped; use wider columns (via column_widths) for better visibility. Font, padding, and ttk.Style must be configured externally to include right-side cell padding (e.g., padding=(0, 0, 5, 0)) for column separation. 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) # 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 treeview_args = { 'master': self.table_frame, 'columns': self.display_columns, 'show': 'headings', 'selectmode': 'extended' if self.multi_select else 'browse', } 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)) # 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 _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( 'Treeview', background=bg, fieldbackground=bg, foreground=fg, bordercolor=bg, borderwidth=1, padding=(0, 0, 8, 0), ) style.map( 'Treeview', background=[('selected', selected_bg)], foreground=[('selected', selected_fg)], ) # 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()