Coverage for openhcs/pyqt_gui/widgets/shared/column_filter_widget.py: 0.0%
253 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-04 02:09 +0000
1"""
2Column filter widget with checkboxes for unique values.
4Provides Excel-like column filtering with checkboxes for each unique value.
5Multiple columns can be filtered simultaneously with AND logic across columns.
6"""
8import logging
9from typing import Dict, Set, List, Optional, Callable
11from PyQt6.QtWidgets import (
12 QWidget, QVBoxLayout, QHBoxLayout, QCheckBox, QPushButton,
13 QScrollArea, QLabel, QFrame, QSplitter
14)
15from PyQt6.QtCore import pyqtSignal, Qt, QSize
17from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
18from openhcs.pyqt_gui.shared.style_generator import StyleSheetGenerator
19from openhcs.pyqt_gui.widgets.shared.layout_constants import COMPACT_LAYOUT
21logger = logging.getLogger(__name__)
24class NonCompressingSplitter(QSplitter):
25 """
26 A QSplitter that maintains its size based on widget sizes, not available space.
28 When handles are moved, this splitter grows the total size instead of
29 redistributing space among widgets.
30 """
32 def __init__(self, *args, **kwargs):
33 super().__init__(*args, **kwargs)
34 # Remove maximum height constraint
35 self.setMaximumHeight(16777215) # QWIDGETSIZE_MAX
36 # Set a reasonable width
37 self.setMinimumWidth(200)
38 # Flag to prevent resize event from interfering
39 self._in_move = False
41 def moveSplitter(self, pos, index):
42 """Override to grow total size instead of redistributing space."""
43 # Get current sizes before any changes
44 old_sizes = self.sizes()
45 if not old_sizes or index <= 0 or index > len(old_sizes):
46 super().moveSplitter(pos, index)
47 return
49 # Set flag to prevent resize interference
50 self._in_move = True
52 # Calculate the position change
53 # The handle is between widget[index-1] and widget[index]
54 old_pos = sum(old_sizes[:index]) + (index * self.handleWidth())
55 delta = pos - old_pos
57 # Create new sizes - only change the widget above the handle
58 new_sizes = old_sizes.copy()
59 new_sizes[index - 1] = max(0, old_sizes[index - 1] + delta)
61 # Don't shrink the widget below - keep all other widgets the same size
62 # This means the total size will grow/shrink
64 # Calculate new total height
65 total_height = sum(new_sizes)
66 num_handles = max(0, self.count() - 1)
67 total_height += num_handles * self.handleWidth()
69 # Set the new sizes FIRST before resizing
70 # This prevents Qt from redistributing space when we resize
71 self.setSizes(new_sizes)
73 # Now update minimum height and resize
74 self.setMinimumHeight(total_height)
75 self.setFixedHeight(total_height)
77 self._in_move = False
79 def resizeEvent(self, event):
80 """Override to prevent automatic size redistribution."""
81 if self._in_move:
82 # During moveSplitter, don't let Qt redistribute sizes
83 super().resizeEvent(event)
84 return
86 # Normal resize - let Qt handle it
87 super().resizeEvent(event)
90class ColumnFilterWidget(QFrame):
91 """
92 Filter widget for a single column showing checkboxes for unique values.
93 Uses compact styling matching parameter form manager.
95 Signals:
96 filter_changed: Emitted when filter selection changes
97 """
99 filter_changed = pyqtSignal()
101 def __init__(self, column_name: str, unique_values: List[str],
102 color_scheme: Optional[PyQt6ColorScheme] = None, parent=None):
103 """
104 Initialize column filter widget.
106 Args:
107 column_name: Name of the column being filtered
108 unique_values: List of unique values in this column
109 color_scheme: Color scheme for styling
110 parent: Parent widget
111 """
112 super().__init__(parent)
113 self.column_name = column_name
114 self.unique_values = sorted(unique_values) # Sort for consistent display
115 self.checkboxes: Dict[str, QCheckBox] = {}
116 self.color_scheme = color_scheme or PyQt6ColorScheme()
117 self.style_gen = StyleSheetGenerator(self.color_scheme)
119 # Apply frame styling
120 self.setFrameStyle(QFrame.Shape.StyledPanel | QFrame.Shadow.Raised)
121 self.setStyleSheet(f"""
122 QFrame {{
123 background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)};
124 border: 1px solid {self.color_scheme.to_hex(self.color_scheme.border_color)};
125 border-radius: 3px;
126 }}
127 """)
129 self._init_ui()
131 def _init_ui(self):
132 """Initialize the UI with compact styling matching parameter form manager."""
133 layout = QVBoxLayout(self)
134 layout.setContentsMargins(*COMPACT_LAYOUT.main_layout_margins)
135 layout.setSpacing(COMPACT_LAYOUT.main_layout_spacing)
137 # Header: Column title on left, buttons on right (same row)
138 header_layout = QHBoxLayout()
139 header_layout.setContentsMargins(0, 0, 0, 0)
140 header_layout.setSpacing(COMPACT_LAYOUT.parameter_row_spacing)
142 # Column title label (bold, accent color)
143 title_label = QLabel(self.column_name)
144 title_label.setStyleSheet(f"""
145 QLabel {{
146 font-weight: bold;
147 color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};
148 font-size: 11px;
149 }}
150 """)
151 header_layout.addWidget(title_label)
153 header_layout.addStretch()
155 # All/None buttons (compact, matching parameter form buttons)
156 select_all_btn = QPushButton("All")
157 select_all_btn.setMaximumWidth(35)
158 select_all_btn.setMaximumHeight(20)
159 select_all_btn.setStyleSheet(self.style_gen.generate_button_style())
160 select_all_btn.clicked.connect(self.select_all)
161 header_layout.addWidget(select_all_btn)
163 select_none_btn = QPushButton("None")
164 select_none_btn.setMaximumWidth(35)
165 select_none_btn.setMaximumHeight(20)
166 select_none_btn.setStyleSheet(self.style_gen.generate_button_style())
167 select_none_btn.clicked.connect(self.select_none)
168 header_layout.addWidget(select_none_btn)
170 layout.addLayout(header_layout)
172 # Scrollable checkbox list - each filter has its own scroll area
173 from PyQt6.QtWidgets import QSizePolicy
174 scroll_area = QScrollArea()
175 scroll_area.setWidgetResizable(True)
176 scroll_area.setMinimumHeight(60) # Minimum to show a few items
177 scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
178 scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
179 scroll_area.setStyleSheet(f"""
180 QScrollArea {{
181 background-color: {self.color_scheme.to_hex(self.color_scheme.window_bg)};
182 border: none;
183 }}
184 """)
186 checkbox_container = QWidget()
187 checkbox_layout = QVBoxLayout(checkbox_container)
188 checkbox_layout.setContentsMargins(0, 0, 0, 0)
189 checkbox_layout.setSpacing(COMPACT_LAYOUT.content_layout_spacing)
191 # Create checkbox for each unique value (compact styling)
192 for value in self.unique_values:
193 checkbox = QCheckBox(str(value))
194 checkbox.setChecked(True) # Start with all selected
195 checkbox.setStyleSheet(f"""
196 QCheckBox {{
197 color: {self.color_scheme.to_hex(self.color_scheme.text_primary)};
198 spacing: 4px;
199 font-size: 11px;
200 }}
201 QCheckBox::indicator {{
202 width: 14px;
203 height: 14px;
204 }}
205 """)
206 checkbox.stateChanged.connect(self._on_checkbox_changed)
207 self.checkboxes[value] = checkbox
208 checkbox_layout.addWidget(checkbox)
210 checkbox_layout.addStretch()
211 scroll_area.setWidget(checkbox_container)
212 # Add scroll area with stretch factor so it takes up available space
213 layout.addWidget(scroll_area, 1)
215 # Count label (compact, secondary text color)
216 self.count_label = QLabel()
217 self.count_label.setStyleSheet(f"""
218 QLabel {{
219 font-size: 10px;
220 color: {self.color_scheme.to_hex(self.color_scheme.text_disabled)};
221 }}
222 """)
223 self._update_count_label()
224 layout.addWidget(self.count_label)
226 def _on_checkbox_changed(self):
227 """Handle checkbox state change."""
228 self._update_count_label()
229 self.filter_changed.emit()
231 def _update_count_label(self):
232 """Update the count label showing selected/total."""
233 selected_count = len(self.get_selected_values())
234 total_count = len(self.unique_values)
235 self.count_label.setText(f"{selected_count}/{total_count} selected")
237 def select_all(self, block_signals: bool = False):
238 """
239 Select all checkboxes.
241 Args:
242 block_signals: If True, block signals while updating checkboxes
243 """
244 for checkbox in self.checkboxes.values():
245 if block_signals:
246 checkbox.blockSignals(True)
247 checkbox.setChecked(True)
248 if block_signals:
249 checkbox.blockSignals(False)
251 if block_signals:
252 self._update_count_label()
254 def select_none(self, block_signals: bool = False):
255 """
256 Deselect all checkboxes.
258 Args:
259 block_signals: If True, block signals while updating checkboxes
260 """
261 for checkbox in self.checkboxes.values():
262 if block_signals:
263 checkbox.blockSignals(True)
264 checkbox.setChecked(False)
265 if block_signals:
266 checkbox.blockSignals(False)
268 if block_signals:
269 self._update_count_label()
271 def get_selected_values(self) -> Set[str]:
272 """Get set of selected values."""
273 return {value for value, checkbox in self.checkboxes.items() if checkbox.isChecked()}
275 def set_selected_values(self, values: Set[str], block_signals: bool = False):
276 """
277 Set which values are selected.
279 Args:
280 values: Set of values to select
281 block_signals: If True, block signals while updating checkboxes to prevent loops
282 """
283 for value, checkbox in self.checkboxes.items():
284 if block_signals:
285 checkbox.blockSignals(True)
286 checkbox.setChecked(value in values)
287 if block_signals:
288 checkbox.blockSignals(False)
290 # Update count label manually if signals were blocked
291 if block_signals:
292 self._update_count_label()
295class MultiColumnFilterPanel(QWidget):
296 """
297 Panel containing filters for multiple columns with resizable splitters.
299 Provides column-based filtering with AND logic across columns.
300 Each filter can be resized independently using vertical splitters.
302 Signals:
303 filters_changed: Emitted when any filter changes
304 """
306 filters_changed = pyqtSignal()
308 def __init__(self, color_scheme: Optional[PyQt6ColorScheme] = None, parent=None):
309 """Initialize multi-column filter panel."""
310 super().__init__(parent)
311 self.column_filters: Dict[str, ColumnFilterWidget] = {}
312 self.color_scheme = color_scheme or PyQt6ColorScheme()
313 self._init_ui()
315 def _init_ui(self):
316 """Initialize the UI with vertical splitter for resizable filters in a scroll area."""
317 from PyQt6.QtWidgets import QSizePolicy, QScrollArea
319 # Use custom non-compressing splitter so each filter can be resized
320 self.splitter = NonCompressingSplitter(Qt.Orientation.Vertical)
321 self.splitter.setChildrenCollapsible(False) # Prevent filters from collapsing
322 self.splitter.setHandleWidth(5) # Make handle more visible and easier to grab
324 # Wrap splitter in scroll area so the whole group can scroll
325 self.scroll_area = QScrollArea()
326 # CRITICAL: setWidgetResizable(False) prevents scroll area from forcing splitter to fit
327 self.scroll_area.setWidgetResizable(False)
328 self.scroll_area.setWidget(self.splitter)
329 self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
330 self.scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
331 self.scroll_area.setFrameShape(QFrame.Shape.NoFrame)
333 main_layout = QVBoxLayout(self)
334 main_layout.setContentsMargins(0, 0, 0, 0)
335 main_layout.setSpacing(0)
336 main_layout.addWidget(self.scroll_area)
338 def resizeEvent(self, event):
339 """Handle resize to update splitter width."""
340 super().resizeEvent(event)
341 # Update splitter width to match scroll area viewport width
342 viewport_width = self.scroll_area.viewport().width()
343 if viewport_width > 0:
344 self.splitter.setFixedWidth(viewport_width)
346 def showEvent(self, event):
347 """Handle show event to ensure proper initial sizing."""
348 super().showEvent(event)
349 # When first shown, ensure splitter has correct width and recalculate sizes
350 viewport_width = self.scroll_area.viewport().width()
351 if viewport_width > 0:
352 self.splitter.setFixedWidth(viewport_width)
353 # Recalculate sizes now that we have proper dimensions
354 if self.column_filters:
355 self._update_splitter_sizes()
357 def add_column_filter(self, column_name: str, unique_values: List[str]):
358 """
359 Add a filter for a column.
361 Args:
362 column_name: Name of the column
363 unique_values: List of unique values in this column
364 """
365 if column_name in self.column_filters:
366 # Remove existing filter
367 self.remove_column_filter(column_name)
369 # Create filter widget with color scheme
370 filter_widget = ColumnFilterWidget(column_name, unique_values, self.color_scheme)
371 filter_widget.filter_changed.connect(self._on_filter_changed)
373 # Add to splitter (each filter is independently resizable)
374 self.splitter.addWidget(filter_widget)
376 self.column_filters[column_name] = filter_widget
378 # Update sizes after adding widget
379 self._update_splitter_sizes()
381 def _update_splitter_sizes(self):
382 """Update splitter sizes based on each filter's content."""
383 num_filters = len(self.column_filters)
384 if num_filters > 0:
385 # Force layout update first to get accurate size hints
386 for filter_widget in self.column_filters.values():
387 filter_widget.updateGeometry()
389 # Size each filter based on its actual content (sizeHint)
390 sizes = []
391 for filter_widget in self.column_filters.values():
392 # Get the widget's preferred size
393 hint = filter_widget.sizeHint()
394 # Use the height hint, with a minimum of 100px
395 sizes.append(max(100, hint.height()))
397 self.splitter.setSizes(sizes)
399 # Set initial minimum height
400 total_height = sum(sizes)
401 num_handles = max(0, num_filters - 1)
402 total_height += num_handles * self.splitter.handleWidth()
403 self.splitter.setMinimumHeight(total_height)
405 # Resize to the calculated height
406 self.splitter.setFixedHeight(total_height)
408 # Schedule a deferred update to fix layout after widgets are fully rendered
409 from PyQt6.QtCore import QTimer
410 QTimer.singleShot(0, self._deferred_size_update)
412 def _deferred_size_update(self):
413 """Deferred size update after widgets are fully rendered."""
414 num_filters = len(self.column_filters)
415 if num_filters > 0:
416 # Force synchronous event processing to ensure layout is complete
417 from PyQt6.QtWidgets import QApplication
418 QApplication.processEvents()
420 # Force a full layout pass first
421 self.splitter.updateGeometry()
422 for filter_widget in self.column_filters.values():
423 filter_widget.layout().activate()
424 filter_widget.updateGeometry()
426 # Process events again after geometry updates
427 QApplication.processEvents()
429 # Recalculate sizes now that widgets are rendered
430 sizes = []
431 for filter_widget in self.column_filters.values():
432 hint = filter_widget.sizeHint()
433 sizes.append(max(100, hint.height()))
435 self.splitter.setSizes(sizes)
437 total_height = sum(sizes)
438 num_handles = max(0, num_filters - 1)
439 total_height += num_handles * self.splitter.handleWidth()
440 self.splitter.setMinimumHeight(total_height)
441 self.splitter.setFixedHeight(total_height)
443 # Force a repaint to ensure proper rendering
444 self.splitter.update()
446 def remove_column_filter(self, column_name: str):
447 """Remove a column filter."""
448 if column_name in self.column_filters:
449 widget = self.column_filters[column_name]
450 # Remove from splitter
451 widget.setParent(None)
452 widget.deleteLater()
453 del self.column_filters[column_name]
454 # Update sizes after removing
455 self._update_splitter_sizes()
457 def clear_all_filters(self):
458 """Remove all column filters."""
459 for column_name in list(self.column_filters.keys()):
460 self.remove_column_filter(column_name)
462 def _on_filter_changed(self):
463 """Handle filter change from any column."""
464 self.filters_changed.emit()
466 def get_active_filters(self) -> Dict[str, Set[str]]:
467 """
468 Get active filters for all columns.
470 Returns:
471 Dictionary mapping column name to set of selected values.
472 Only includes columns where not all values are selected.
473 """
474 active_filters = {}
475 for column_name, filter_widget in self.column_filters.items():
476 selected = filter_widget.get_selected_values()
477 # Only include if not all values are selected (i.e., actually filtering)
478 if len(selected) < len(filter_widget.unique_values):
479 active_filters[column_name] = selected
480 return active_filters
482 def apply_filters(self, data: List[Dict], column_key_map: Optional[Dict[str, str]] = None) -> List[Dict]:
483 """
484 Apply filters to a list of data dictionaries.
486 Args:
487 data: List of dictionaries to filter
488 column_key_map: Optional mapping from display column names to data keys
489 (e.g., {"Well": "well", "Channel": "channel"})
491 Returns:
492 Filtered list of dictionaries
493 """
494 active_filters = self.get_active_filters()
496 if not active_filters:
497 return data # No filters active
499 # Map column names to data keys
500 if column_key_map is None:
501 column_key_map = {name: name.lower().replace(' ', '_') for name in active_filters.keys()}
503 # Filter data with AND logic across columns
504 filtered_data = []
505 for item in data:
506 matches = True
507 for column_name, selected_values in active_filters.items():
508 data_key = column_key_map.get(column_name, column_name)
509 item_value = str(item.get(data_key, ''))
510 if item_value not in selected_values:
511 matches = False
512 break
513 if matches:
514 filtered_data.append(item)
516 return filtered_data
518 def reset_all_filters(self):
519 """Reset all filters to select all values."""
520 for filter_widget in self.column_filters.values():
521 filter_widget.select_all()