"""
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)
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 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