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 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, ) -> 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.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() 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.""" logger.debug('Populating table with %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 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] 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. Args: new_data: List of dictionaries containing new row data. """ logger.debug('Updating data with %d rows', len(new_data)) self.data = new_data self._populate_data()
[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. """ def __init__( self, parent, columns, data, action_button_text='Take Action', action_callback=None, enable_select_all=True, checked_by_default=True, max_height=None, column_widths=None, geometry_manager='pack', ): super().__init__(parent) self.base_columns = columns self.columns = ['☑'] + columns self.action_callback = action_callback 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.action_button_text = action_button_text self._geometry_manager = geometry_manager self._prepare_data(data) self._build_ui() def _prepare_data(self, data): # Store checked state per row self.data = [] self.check_vars = [] for row in data or []: var = tk.BooleanVar(value=self.checked_by_default) r = dict(row) r['☑'] = var self.data.append(r) self.check_vars.append(var) 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): # Redraw DataTable with current check states rendered_data = self._render_table_data() self.data_table.update_data(rendered_data) 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() def _toggle_select_all(self): currently_all = all(var.get() for var in self.check_vars) for var in self.check_vars: var.set(not currently_all) self._rebuild_table() self._update_select_all_label() 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')
[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): self._prepare_data(data) self._rebuild_table() self._update_select_all_label()
def _handle_action(self): checked = self.get_checked_rows() if self.action_callback: self.action_callback(checked) def _build_ui(self): # DataTable instantiation self.data_table = DataTable( self, columns=self.columns, display_columns=self.columns, data=self._render_table_data(), sortable=False, row_colors=('#FFFFFF', '#F7F7F7'), multi_select=False, column_widths=self.column_widths, ) 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) btn_row = tk.Frame(self) 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 self.action_btn = ttk.Button(btn_row, text=self.action_button_text, width=16, command=self._handle_action) self.action_btn.grid(row=0, column=col_offset, 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)) self.action_btn = ttk.Button(btn_row, text=self.action_button_text, width=16, command=self._handle_action) self.action_btn.pack(side='left') self._update_select_all_label()