"""Advanced table widgets with filtering, sorting, and search capabilities."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QBrush
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QTableWidget,
QTableWidgetItem,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
if TYPE_CHECKING:
from PySide6.QtGui import QColor
[docs]
class DataTable(QWidget):
"""Enhanced data table widget with filtering and sorting."""
row_selected = Signal(int)
cell_edited = Signal(int, int, str)
row_double_clicked = Signal(int)
def __init__(
self,
rows: int = 0,
columns: int = 0,
headers: list[str] | None = None,
parent: QWidget | None = None,
) -> None:
"""Initialize data table."""
super().__init__(parent)
# Main layout
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Search/filter bar
filter_layout = QHBoxLayout()
filter_layout.setContentsMargins(0, 0, 0, 5)
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Search...")
self.search_input.textChanged.connect(self._on_search_changed)
self.column_filter = QComboBox()
self.column_filter.addItem("All Columns")
if headers:
self.column_filter.addItems(headers)
self.column_filter.currentIndexChanged.connect(self._on_filter_column_changed)
filter_layout.addWidget(QLabel("Filter:"))
filter_layout.addWidget(self.search_input)
filter_layout.addWidget(QLabel("in"))
filter_layout.addWidget(self.column_filter)
filter_layout.addStretch()
layout.addLayout(filter_layout)
# Table widget
self.table = QTableWidget(rows, columns)
if headers:
self.set_headers(headers)
# Configure table
self.table.setAlternatingRowColors(True)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.horizontalHeader().setStretchLastSection(True)
self.table.verticalHeader().setVisible(False)
self.table.setSortingEnabled(True)
# Connect signals
self.table.itemSelectionChanged.connect(self._on_selection_changed)
self.table.itemChanged.connect(self._on_item_changed)
self.table.itemDoubleClicked.connect(self._on_item_double_clicked)
layout.addWidget(self.table)
# Store original data for filtering
self._original_data: list[list[Any]] = []
self._filter_column = -1 # -1 means all columns
# Apply theme-based styling and connect to theme changes
self._apply_theme_style()
self._connect_theme_signals()
def _apply_theme_style(self) -> None:
"""Apply theme-based styling to the table."""
# Get theme from application
app = QApplication.instance()
if app and hasattr(app, "theme_manager"):
theme = app.theme_manager.get_theme()
if theme and theme.colors:
colors = theme.colors
self.table.setStyleSheet(f"""
QTableWidget {{
gridline-color: {colors.table_grid};
selection-background-color: {colors.table_row_selected};
background-color: {colors.background};
}}
QTableWidget::item {{
padding: 5px;
color: {colors.text_primary};
}}
QTableWidget::item:alternate {{
background-color: {colors.table_row_bg_alt};
}}
QTableWidget::item:hover {{
background-color: {colors.table_row_hover};
}}
QHeaderView::section {{
background-color: {colors.table_header_bg};
color: {colors.text_primary};
padding: 5px;
border: 1px solid {colors.table_grid};
font-weight: bold;
}}
""")
return
# Fallback to palette-based styling
self.table.setStyleSheet("""
QTableWidget {
gridline-color: palette(mid);
selection-background-color: palette(highlight);
}
QTableWidget::item {
padding: 5px;
}
QHeaderView::section {
background-color: palette(alternate-base);
color: palette(text);
padding: 5px;
border: 1px solid palette(mid);
font-weight: bold;
}
""")
def _connect_theme_signals(self) -> None:
"""Connect to theme change signals."""
app = QApplication.instance()
if app and hasattr(app, "theme_manager"):
app.theme_manager.theme_changed.connect(self._on_theme_changed)
def _on_theme_changed(self, theme_name: str) -> None:
"""Handle theme change."""
self._apply_theme_style()
[docs]
def set_data(self, data: list[list[Any]]) -> None:
"""Set table data.
Args:
data: 2D list of data
"""
self._original_data = [row[:] for row in data] # Store copy
self.table.setRowCount(len(data))
if data:
self.table.setColumnCount(len(data[0]))
for row, row_data in enumerate(data):
for col, value in enumerate(row_data):
item = QTableWidgetItem(str(value))
self.table.setItem(row, col, item)
[docs]
def get_data(self) -> list[list[str]]:
"""Get table data.
Returns:
2D list of data
"""
data = []
for row in range(self.table.rowCount()):
row_data = []
for col in range(self.table.columnCount()):
item = self.table.item(row, col)
row_data.append(item.text() if item else "")
data.append(row_data)
return data
[docs]
def add_row(self, data: list[Any] | None = None) -> None:
"""Add a row to the table.
Args:
data: Row data
"""
row_count = self.table.rowCount()
self.table.insertRow(row_count)
if data:
for col, value in enumerate(data):
item = QTableWidgetItem(str(value))
self.table.setItem(row_count, col, item)
# Add to original data
self._original_data.append(data[:])
[docs]
def remove_selected_rows(self) -> None:
"""Remove selected rows."""
rows: set[int] = set()
rows.update(item.row() for item in self.table.selectedItems())
for row in sorted(rows, reverse=True):
self.table.removeRow(row)
if row < len(self._original_data):
self._original_data.pop(row)
[docs]
def clear_selection(self) -> None:
"""Clear current selection."""
self.table.clearSelection()
[docs]
def select_row(self, row: int) -> None:
"""Select a specific row."""
self.table.selectRow(row)
[docs]
def get_selected_rows(self) -> list[int]:
"""Get list of selected row indices."""
rows: set[int] = set()
rows.update(item.row() for item in self.table.selectedItems())
return sorted(rows)
def _on_selection_changed(self) -> None:
"""Handle selection change."""
selected = self.table.selectedItems()
if selected:
self.row_selected.emit(selected[0].row())
def _on_item_changed(self, item: QTableWidgetItem) -> None:
"""Handle item change."""
if item:
self.cell_edited.emit(item.row(), item.column(), item.text())
def _on_item_double_clicked(self, item: QTableWidgetItem) -> None:
"""Handle item double click."""
if item:
self.row_double_clicked.emit(item.row())
def _on_search_changed(self, text: str) -> None:
"""Handle search text change."""
self._apply_filter(text)
def _on_filter_column_changed(self, index: int) -> None:
"""Handle filter column change."""
self._filter_column = index - 1 # -1 for "All Columns"
self._apply_filter(self.search_input.text())
def _apply_filter(self, filter_text: str) -> None:
"""Apply filter to table rows."""
filter_text = filter_text.lower()
for row in range(self.table.rowCount()):
should_hide = True
if self._filter_column == -1: # All columns
for col in range(self.table.columnCount()):
item = self.table.item(row, col)
if item and filter_text in item.text().lower():
should_hide = False
break
else: # Specific column
item = self.table.item(row, self._filter_column)
if item and filter_text in item.text().lower():
should_hide = False
self.table.setRowHidden(row, should_hide if filter_text else False)
[docs]
def enable_sorting(self, enable: bool = True) -> None:
"""Enable column sorting.
Args:
enable: Enable sorting
"""
self.table.setSortingEnabled(enable)
[docs]
def set_column_width(self, column: int, width: int) -> None:
"""Set column width."""
self.table.setColumnWidth(column, width)
[docs]
def set_row_color(self, row: int, color: QColor) -> None:
"""Set background color for a row."""
for col in range(self.table.columnCount()):
item = self.table.item(row, col)
if item:
item.setBackground(QBrush(color))
[docs]
class TreeTable(QWidget):
"""Enhanced tree table widget with search and expandable rows."""
item_selected = Signal(str)
item_expanded = Signal(str)
item_double_clicked = Signal(str)
def __init__(
self,
headers: list[str] | None = None,
parent: QWidget | None = None,
) -> None:
"""Initialize tree table."""
super().__init__(parent)
# Main layout
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Search bar
search_layout = QHBoxLayout()
search_layout.setContentsMargins(0, 0, 0, 5)
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Search tree...")
self.search_input.textChanged.connect(self._on_search_changed)
self.expand_all_btn = QPushButton("Expand All")
self.expand_all_btn.clicked.connect(self._expand_all)
self.collapse_all_btn = QPushButton("Collapse All")
self.collapse_all_btn.clicked.connect(self._collapse_all)
search_layout.addWidget(QLabel("Search:"))
search_layout.addWidget(self.search_input)
search_layout.addStretch()
search_layout.addWidget(self.expand_all_btn)
search_layout.addWidget(self.collapse_all_btn)
layout.addLayout(search_layout)
# Tree widget
self.tree = QTreeWidget()
if headers:
self.set_headers(headers)
# Configure tree
self.tree.setAlternatingRowColors(True)
self.tree.setAnimated(True)
self.tree.setExpandsOnDoubleClick(True)
self.tree.setSortingEnabled(True)
# Connect signals
self._setup_connections()
layout.addWidget(self.tree)
# Apply theme-based styling and connect to theme changes
self._apply_theme_style()
self._connect_theme_signals()
def _apply_theme_style(self) -> None:
"""Apply theme-based styling to the tree."""
# Get theme from application
app = QApplication.instance()
if app and hasattr(app, "theme_manager"):
theme = app.theme_manager.get_theme()
if theme and theme.colors:
colors = theme.colors
self.tree.setStyleSheet(f"""
QTreeWidget {{
selection-background-color: {colors.table_row_selected};
background-color: {colors.background};
}}
QTreeWidget::item {{
padding: 3px;
color: {colors.text_primary};
}}
QTreeWidget::item:hover {{
background-color: {colors.table_row_hover};
}}
QTreeWidget::item:selected {{
background-color: {colors.table_row_selected};
}}
QHeaderView::section {{
background-color: {colors.table_header_bg};
color: {colors.text_primary};
padding: 5px;
border: 1px solid {colors.table_grid};
font-weight: bold;
}}
""")
return
# Fallback to palette-based styling
self.tree.setStyleSheet("""
QTreeWidget {
selection-background-color: palette(highlight);
}
QTreeWidget::item {
padding: 3px;
}
QHeaderView::section {
background-color: palette(alternate-base);
color: palette(text);
padding: 5px;
border: 1px solid palette(mid);
font-weight: bold;
}
""")
def _connect_theme_signals(self) -> None:
"""Connect to theme change signals."""
app = QApplication.instance()
if app and hasattr(app, "theme_manager"):
app.theme_manager.theme_changed.connect(self._on_theme_changed)
def _on_theme_changed(self, theme_name: str) -> None:
"""Handle theme change."""
self._apply_theme_style()
def _setup_connections(self) -> None:
"""Setup signal connections."""
self.tree.itemSelectionChanged.connect(self._on_selection_changed)
self.tree.itemExpanded.connect(self._on_item_expanded)
self.tree.itemDoubleClicked.connect(self._on_item_double_clicked)
[docs]
def add_item(
self,
parent_item: QTreeWidgetItem | None,
values: list[str],
data: Any = None,
) -> QTreeWidgetItem:
"""Add item to tree.
Args:
parent_item: Parent item (None for root)
values: Column values
data: Item data
Returns:
Created tree item
"""
if parent_item:
item = QTreeWidgetItem(parent_item, values)
else:
item = QTreeWidgetItem(self.tree, values)
if data:
item.setData(0, Qt.ItemDataRole.UserRole, data)
return item
[docs]
def get_selected_item(self) -> QTreeWidgetItem | None:
"""Get selected item."""
items = self.tree.selectedItems()
return items[0] if items else None
[docs]
def get_item_data(self, item: QTreeWidgetItem) -> Any:
"""Get data stored in item."""
return item.data(0, Qt.ItemDataRole.UserRole)
[docs]
def clear(self) -> None:
"""Clear all items."""
self.tree.clear()
def _on_selection_changed(self) -> None:
"""Handle selection change."""
selected = self.tree.selectedItems()
if selected:
self.item_selected.emit(selected[0].text(0))
def _on_item_expanded(self, item: QTreeWidgetItem) -> None:
"""Handle item expansion."""
if item:
self.item_expanded.emit(item.text(0))
def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
"""Handle item double click."""
if item:
self.item_double_clicked.emit(item.text(0))
def _on_search_changed(self, text: str) -> None:
"""Handle search text change."""
self._apply_search_filter(text.lower())
def _apply_search_filter(self, search_text: str) -> None:
"""Apply search filter to tree items."""
def filter_item(item: QTreeWidgetItem, text: str) -> bool:
"""Recursively filter items."""
# Check if item matches
item_matches = False
for col in range(item.columnCount()):
if text in item.text(col).lower():
item_matches = True
break
# Check children
child_matches = False
for i in range(item.childCount()):
child = item.child(i)
if filter_item(child, text):
child_matches = True
# Show item if it or any child matches
should_show = item_matches or child_matches or not text
item.setHidden(not should_show)
# Expand if children match but item doesn't
if child_matches and not item_matches and text:
item.setExpanded(True)
return should_show
# Apply to all root items
for i in range(self.tree.topLevelItemCount()):
item = self.tree.topLevelItem(i)
if item is not None:
filter_item(item, search_text)
def _expand_all(self) -> None:
"""Expand all tree items."""
self.tree.expandAll()
def _collapse_all(self) -> None:
"""Collapse all tree items."""
self.tree.collapseAll()
[docs]
def set_column_width(self, column: int, width: int) -> None:
"""Set column width."""
self.tree.setColumnWidth(column, width)
[docs]
def set_item_color(self, item: QTreeWidgetItem, color: QColor) -> None:
"""Set item background color."""
for col in range(item.columnCount()):
item.setBackground(col, QBrush(color))
[docs]
def set_item_icon(self, item: QTreeWidgetItem, column: int, icon: Any) -> None:
"""Set item icon."""
item.setIcon(column, icon)
[docs]
class PivotTable(QWidget):
"""Pivot table widget for data aggregation."""
def __init__(self, parent: QWidget | None = None) -> None:
"""Initialize pivot table."""
super().__init__(parent)
layout = QVBoxLayout(self)
# Controls
controls_layout = QHBoxLayout()
self.row_field = QComboBox()
self.col_field = QComboBox()
self.value_field = QComboBox()
self.agg_function = QComboBox()
self.agg_function.addItems(["Sum", "Average", "Count", "Min", "Max"])
controls_layout.addWidget(QLabel("Rows:"))
controls_layout.addWidget(self.row_field)
controls_layout.addWidget(QLabel("Columns:"))
controls_layout.addWidget(self.col_field)
controls_layout.addWidget(QLabel("Values:"))
controls_layout.addWidget(self.value_field)
controls_layout.addWidget(QLabel("Aggregate:"))
controls_layout.addWidget(self.agg_function)
refresh_btn = QPushButton("Refresh")
refresh_btn.clicked.connect(self._refresh_pivot)
controls_layout.addWidget(refresh_btn)
controls_layout.addStretch()
layout.addLayout(controls_layout)
# Pivot table
self.table = QTableWidget()
self.table.setAlternatingRowColors(True)
layout.addWidget(self.table)
self._source_data: list[dict[str, Any]] = []
[docs]
def set_data(self, data: list[dict[str, Any]]) -> None:
"""Set source data for pivot table."""
self._source_data = data
if data:
# Update field combos
fields = list(data[0].keys())
self.row_field.clear()
self.row_field.addItems(fields)
self.col_field.clear()
self.col_field.addItems(fields)
self.value_field.clear()
self.value_field.addItems(fields)
def _refresh_pivot(self) -> None:
"""Refresh pivot table based on current settings."""
if not self._source_data:
return
row_field = self.row_field.currentText()
col_field = self.col_field.currentText()
value_field = self.value_field.currentText()
agg_func = self.agg_function.currentText()
if not all([row_field, col_field, value_field]):
return
# Calculate pivot
pivot_data = self._calculate_pivot(row_field, col_field, value_field, agg_func)
# Display in table
self._display_pivot(pivot_data, row_field, col_field)
def _calculate_pivot(
self,
row_field: str,
col_field: str,
value_field: str,
agg_func: str,
) -> dict[str, dict[str, float]]:
"""Calculate pivot table data."""
pivot: dict[str, dict[str, Any]] = {}
for record in self._source_data:
row_val = str(record.get(row_field, ""))
col_val = str(record.get(col_field, ""))
value = record.get(value_field, 0)
if row_val not in pivot:
pivot[row_val] = {}
if col_val not in pivot[row_val]:
pivot[row_val][col_val] = []
try:
pivot[row_val][col_val].append(float(value))
except (ValueError, TypeError):
pivot[row_val][col_val].append(0)
# Apply aggregation
for row_val in pivot:
for col_val in pivot[row_val]:
values = pivot[row_val][col_val]
if agg_func == "Sum":
pivot[row_val][col_val] = sum(values)
elif agg_func == "Average":
pivot[row_val][col_val] = sum(values) / len(values) if values else 0
elif agg_func == "Count":
pivot[row_val][col_val] = len(values)
elif agg_func == "Min":
pivot[row_val][col_val] = min(values) if values else 0
elif agg_func == "Max":
pivot[row_val][col_val] = max(values) if values else 0
return pivot
def _display_pivot(
self,
pivot_data: dict[str, dict[str, float]],
row_label: str,
col_label: str,
) -> None:
"""Display pivot data in table."""
# Get unique column values
col_values: set[str] = set()
for row_data in pivot_data.values():
col_values.update(row_data.keys())
col_values_sorted = sorted(col_values)
# Set up table
self.table.setRowCount(len(pivot_data))
self.table.setColumnCount(len(col_values_sorted) + 1)
self.table.setHorizontalHeaderLabels([row_label, *col_values_sorted])
# Fill table
for row_idx, (row_val, row_data) in enumerate(sorted(pivot_data.items())):
# Row header
self.table.setItem(row_idx, 0, QTableWidgetItem(row_val))
# Data cells
for col_idx, col_val in enumerate(col_values_sorted):
value = row_data.get(col_val, 0)
item = QTableWidgetItem(f"{value:.2f}")
self.table.setItem(row_idx, col_idx + 1, item)