Source code for pyqt_reactive.widgets.shared.abstract_table_browser

"""
Abstract base class for table-based browser widgets.

Provides common infrastructure for widgets that display searchable, filterable
table views of item collections. Subclasses implement the abstract methods
to customize column layout, row population, and event handling.
"""

from abc import abstractmethod
from dataclasses import dataclass
from typing import Dict, List, Optional, Generic, TypeVar, Literal

from PyQt6.QtWidgets import (
    QWidget, QVBoxLayout, QLineEdit, QTableWidget, QTableWidgetItem,
    QHeaderView, QAbstractItemView, QLabel
)
from PyQt6.QtCore import Qt, pyqtSignal, QTimer

from pyqt_reactive.theming import ColorScheme

from pyqt_reactive.services.search_service import SearchService
from pyqt_reactive.theming import StyleSheetGenerator

T = TypeVar('T')

SelectionMode = Literal['single', 'multi']


[docs] @dataclass class ColumnDef: """Declarative column configuration for table browsers.""" name: str key: str width: Optional[int] = None sortable: bool = True resizable: bool = True
[docs] class AbstractTableBrowser(QWidget, Generic[T]): """ Abstract base class for table-based browser widgets. Provides: - Table widget with configurable columns (static or dynamic) - Search input with SearchService integration - Status label showing item counts - Row selection handling (single or multi-select) Subclasses must implement abstract methods to customize behavior. """ # Signals for selection events item_selected = pyqtSignal(str, object) # key, item item_double_clicked = pyqtSignal(str, object) # key, item items_selected = pyqtSignal(list) # list of keys (for multi-select) INCREMENTAL_POPULATE_THRESHOLD = 750 INCREMENTAL_BATCH_SIZE = 200
[docs] def __init__( self, color_scheme: Optional[ColorScheme] = None, selection_mode: SelectionMode = 'single', parent=None ): super().__init__(parent) self.color_scheme = color_scheme or ColorScheme() self.style_gen = StyleSheetGenerator(self.color_scheme) self._selection_mode = selection_mode # Data storage self.all_items: Dict[str, T] = {} self.filtered_items: Dict[str, T] = {} # Will be set by subclass or set_items() self._search_service: Optional[SearchService[T]] = None self._populate_token = 0 # Create UI components self._setup_ui() self._setup_connections()
def _setup_ui(self): """Set up the base UI components.""" layout = QVBoxLayout(self) layout.setContentsMargins(5, 5, 5, 5) layout.setSpacing(5) # Search input self.search_input = QLineEdit() self.search_input.setPlaceholderText(self.get_search_placeholder()) layout.addWidget(self.search_input) # Status label self.status_label = QLabel("No items loaded") layout.addWidget(self.status_label) # Table widget self.table_widget = QTableWidget() self._configure_table() layout.addWidget(self.table_widget, 1) # Stretch to fill # Apply styling self.table_widget.setStyleSheet(self.style_gen.generate_table_widget_style()) def _configure_table(self): """Configure table based on column definitions.""" columns = self.get_columns() self._apply_column_config(columns) # Configure selection mode self.table_widget.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) qt_mode = ( QAbstractItemView.SelectionMode.ExtendedSelection if self._selection_mode == 'multi' else QAbstractItemView.SelectionMode.SingleSelection ) self.table_widget.setSelectionMode(qt_mode) self.table_widget.setSortingEnabled(True) # Configure text display for proper truncation with ellipsis self.table_widget.setWordWrap(False) self.table_widget.setTextElideMode(Qt.TextElideMode.ElideRight) if hasattr(self.table_widget, "setUniformRowHeights"): self.table_widget.setUniformRowHeights(True) def _apply_column_config(self, columns: List[ColumnDef]): """Apply column configuration to table. Called by _configure_table and reconfigure_columns.""" self.table_widget.setColumnCount(len(columns)) self.table_widget.setHorizontalHeaderLabels([col.name for col in columns]) # Configure header header = self.table_widget.horizontalHeader() header.setSectionsMovable(True) for i, col in enumerate(columns): mode = QHeaderView.ResizeMode.Interactive if col.resizable else QHeaderView.ResizeMode.Fixed header.setSectionResizeMode(i, mode) if col.width: self.table_widget.setColumnWidth(i, col.width)
[docs] def reconfigure_columns(self): """Reconfigure table columns. Call when get_columns() returns different values.""" columns = self.get_columns() self._apply_column_config(columns)
def _setup_connections(self): """Connect signals to slots.""" self.search_input.textChanged.connect(self._on_search_changed) self.table_widget.itemSelectionChanged.connect(self._on_selection_changed) self.table_widget.itemDoubleClicked.connect(self._on_double_click) def _on_search_changed(self, search_term: str): """Handle search input changes.""" # _search_service is set by set_items() which must be called before use self.filtered_items = self._search_service.filter(search_term) self.populate_table(self.filtered_items) self._update_status() def _on_selection_changed(self): """Handle table selection changes.""" selected_keys = self.get_selected_keys() if not selected_keys: return # Valid: user clicked empty area if self._selection_mode == 'multi': # Multi-select: emit list of keys self.items_selected.emit(selected_keys) self.on_items_selected(selected_keys) else: # Single-select: emit first key and item key = selected_keys[0] item = self.filtered_items[key] self.item_selected.emit(key, item) self.on_item_selected(key, item) def _on_double_click(self, table_item: QTableWidgetItem): """Handle double-click on table row.""" row = table_item.row() key_item = self.table_widget.item(row, 0) key = key_item.data(Qt.ItemDataRole.UserRole) # Key in table → item in filtered_items (invariant) item = self.filtered_items[key] self.item_double_clicked.emit(key, item) self.on_item_double_clicked(key, item)
[docs] def get_selected_keys(self) -> List[str]: """Return list of selected item keys. Works for both single and multi-select.""" selected_rows = set() for table_item in self.table_widget.selectedItems(): selected_rows.add(table_item.row()) keys = [] for row in sorted(selected_rows): key_item = self.table_widget.item(row, 0) keys.append(key_item.data(Qt.ItemDataRole.UserRole)) return keys
def _update_status(self): """Update status label with current counts.""" total = len(self.all_items) filtered = len(self.filtered_items) self.status_label.setText(f"Showing {filtered}/{total} items")
[docs] def set_items(self, items: Dict[str, T]): """Set items to display in the table.""" self.all_items = items self.filtered_items = items.copy() # Initialize search service only if all_items changed (not just filtered_items) if self._search_service is None: self._search_service = SearchService( all_items=self.all_items, searchable_text_extractor=self.get_searchable_text ) else: self._search_service.update_items(self.all_items) self._populate_token += 1 if len(self.filtered_items) > self.INCREMENTAL_POPULATE_THRESHOLD: self.populate_table_incremental( self.filtered_items, token=self._populate_token, batch_size=self.INCREMENTAL_BATCH_SIZE, ) else: self.populate_table(self.filtered_items) self._update_status()
[docs] def set_filtered_items(self, filtered_items: Dict[str, T]): """Update filtered items without recreating SearchService. Use this when all_items hasn't changed, only filter criteria changed. Much faster than set_items() for checkbox filter updates. """ self.filtered_items = filtered_items self._populate_token += 1 if len(self.filtered_items) > self.INCREMENTAL_POPULATE_THRESHOLD: self.populate_table_incremental( self.filtered_items, token=self._populate_token, batch_size=self.INCREMENTAL_BATCH_SIZE, ) else: self.populate_table(self.filtered_items) self._update_status()
[docs] def populate_table(self, items: Dict[str, T]): """Populate the table with the given items.""" sorting_enabled = self.table_widget.isSortingEnabled() self.table_widget.setSortingEnabled(False) self.table_widget.setUpdatesEnabled(False) self.table_widget.blockSignals(True) try: self.table_widget.setRowCount(len(items)) columns = self.get_columns() for row, (key, item) in enumerate(items.items()): row_data = self.extract_row_data(item) for col, value in enumerate(row_data): table_item = QTableWidgetItem(str(value)) # Store key in first column for lookup if col == 0: table_item.setData(Qt.ItemDataRole.UserRole, key) # Enable proper text truncation with ellipsis table_item.setTextAlignment( Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter ) table_item.setFlags( table_item.flags() & ~Qt.ItemFlag.ItemIsEditable ) self.table_widget.setItem(row, col, table_item) finally: self.table_widget.blockSignals(False) self.table_widget.setUpdatesEnabled(True) self.table_widget.setSortingEnabled(sorting_enabled)
[docs] def populate_table_incremental( self, items: Dict[str, T], *, token: int, batch_size: int = 200, ) -> None: """Populate table in batches to avoid blocking the UI.""" sorting_enabled = self.table_widget.isSortingEnabled() self.table_widget.setSortingEnabled(False) self.table_widget.blockSignals(True) items_list = list(items.items()) self.table_widget.setRowCount(len(items_list)) columns = self.get_columns() def fill_batch(start_index: int) -> None: if token != self._populate_token: return end_index = min(start_index + batch_size, len(items_list)) self.table_widget.setUpdatesEnabled(False) try: for row in range(start_index, end_index): key, item = items_list[row] row_data = self.extract_row_data(item) for col, value in enumerate(row_data): table_item = QTableWidgetItem(str(value)) if col == 0: table_item.setData(Qt.ItemDataRole.UserRole, key) table_item.setTextAlignment( Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter ) table_item.setFlags( table_item.flags() & ~Qt.ItemFlag.ItemIsEditable ) self.table_widget.setItem(row, col, table_item) finally: self.table_widget.setUpdatesEnabled(True) if end_index < len(items_list): QTimer.singleShot(0, lambda: fill_batch(end_index)) else: self.table_widget.blockSignals(False) self.table_widget.setSortingEnabled(sorting_enabled) QTimer.singleShot(0, lambda: fill_batch(0))
[docs] def refresh(self): """Refresh the table display.""" self.populate_table(self.filtered_items) self._update_status()
# ========================================================================= # Abstract methods - subclasses must implement # =========================================================================
[docs] @abstractmethod def get_columns(self) -> List[ColumnDef]: """Return column definitions for the table.""" raise NotImplementedError
[docs] @abstractmethod def extract_row_data(self, item: T) -> List[str]: """Extract display values for a table row from an item.""" raise NotImplementedError
[docs] @abstractmethod def get_searchable_text(self, item: T) -> str: """Return searchable text for an item.""" raise NotImplementedError
# ========================================================================= # Optional hooks - subclasses can override # =========================================================================
[docs] def get_search_placeholder(self) -> str: """Return placeholder text for search input.""" return "Search..."
[docs] def on_item_selected(self, key: str, item: T): """Called when an item is selected (single-select mode). Override to handle.""" pass
[docs] def on_items_selected(self, keys: List[str]): """Called when items are selected (multi-select mode). Override to handle.""" pass
[docs] def on_item_double_clicked(self, key: str, item: T): """Called when an item is double-clicked. Override to handle action.""" pass