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

1""" 

2Column filter widget with checkboxes for unique values. 

3 

4Provides Excel-like column filtering with checkboxes for each unique value. 

5Multiple columns can be filtered simultaneously with AND logic across columns. 

6""" 

7 

8import logging 

9from typing import Dict, Set, List, Optional, Callable 

10 

11from PyQt6.QtWidgets import ( 

12 QWidget, QVBoxLayout, QHBoxLayout, QCheckBox, QPushButton, 

13 QScrollArea, QLabel, QFrame, QSplitter 

14) 

15from PyQt6.QtCore import pyqtSignal, Qt, QSize 

16 

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 

20 

21logger = logging.getLogger(__name__) 

22 

23 

24class NonCompressingSplitter(QSplitter): 

25 """ 

26 A QSplitter that maintains its size based on widget sizes, not available space. 

27 

28 When handles are moved, this splitter grows the total size instead of 

29 redistributing space among widgets. 

30 """ 

31 

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 

40 

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 

48 

49 # Set flag to prevent resize interference 

50 self._in_move = True 

51 

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 

56 

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) 

60 

61 # Don't shrink the widget below - keep all other widgets the same size 

62 # This means the total size will grow/shrink 

63 

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

68 

69 # Set the new sizes FIRST before resizing 

70 # This prevents Qt from redistributing space when we resize 

71 self.setSizes(new_sizes) 

72 

73 # Now update minimum height and resize 

74 self.setMinimumHeight(total_height) 

75 self.setFixedHeight(total_height) 

76 

77 self._in_move = False 

78 

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 

85 

86 # Normal resize - let Qt handle it 

87 super().resizeEvent(event) 

88 

89 

90class ColumnFilterWidget(QFrame): 

91 """ 

92 Filter widget for a single column showing checkboxes for unique values. 

93 Uses compact styling matching parameter form manager. 

94 

95 Signals: 

96 filter_changed: Emitted when filter selection changes 

97 """ 

98 

99 filter_changed = pyqtSignal() 

100 

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. 

105 

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) 

118 

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

128 

129 self._init_ui() 

130 

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) 

136 

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) 

141 

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) 

152 

153 header_layout.addStretch() 

154 

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) 

162 

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) 

169 

170 layout.addLayout(header_layout) 

171 

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

185 

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) 

190 

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) 

209 

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) 

214 

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) 

225 

226 def _on_checkbox_changed(self): 

227 """Handle checkbox state change.""" 

228 self._update_count_label() 

229 self.filter_changed.emit() 

230 

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

236 

237 def select_all(self, block_signals: bool = False): 

238 """ 

239 Select all checkboxes. 

240 

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) 

250 

251 if block_signals: 

252 self._update_count_label() 

253 

254 def select_none(self, block_signals: bool = False): 

255 """ 

256 Deselect all checkboxes. 

257 

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) 

267 

268 if block_signals: 

269 self._update_count_label() 

270 

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()} 

274 

275 def set_selected_values(self, values: Set[str], block_signals: bool = False): 

276 """ 

277 Set which values are selected. 

278 

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) 

289 

290 # Update count label manually if signals were blocked 

291 if block_signals: 

292 self._update_count_label() 

293 

294 

295class MultiColumnFilterPanel(QWidget): 

296 """ 

297 Panel containing filters for multiple columns with resizable splitters. 

298 

299 Provides column-based filtering with AND logic across columns. 

300 Each filter can be resized independently using vertical splitters. 

301 

302 Signals: 

303 filters_changed: Emitted when any filter changes 

304 """ 

305 

306 filters_changed = pyqtSignal() 

307 

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

314 

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 

318 

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 

323 

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) 

332 

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) 

337 

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) 

345 

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

356 

357 def add_column_filter(self, column_name: str, unique_values: List[str]): 

358 """ 

359 Add a filter for a column. 

360 

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) 

368 

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) 

372 

373 # Add to splitter (each filter is independently resizable) 

374 self.splitter.addWidget(filter_widget) 

375 

376 self.column_filters[column_name] = filter_widget 

377 

378 # Update sizes after adding widget 

379 self._update_splitter_sizes() 

380 

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

388 

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

396 

397 self.splitter.setSizes(sizes) 

398 

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) 

404 

405 # Resize to the calculated height 

406 self.splitter.setFixedHeight(total_height) 

407 

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) 

411 

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

419 

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

425 

426 # Process events again after geometry updates 

427 QApplication.processEvents() 

428 

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

434 

435 self.splitter.setSizes(sizes) 

436 

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) 

442 

443 # Force a repaint to ensure proper rendering 

444 self.splitter.update() 

445 

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

456 

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) 

461 

462 def _on_filter_changed(self): 

463 """Handle filter change from any column.""" 

464 self.filters_changed.emit() 

465 

466 def get_active_filters(self) -> Dict[str, Set[str]]: 

467 """ 

468 Get active filters for all columns. 

469  

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 

481 

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. 

485  

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

490  

491 Returns: 

492 Filtered list of dictionaries 

493 """ 

494 active_filters = self.get_active_filters() 

495 

496 if not active_filters: 

497 return data # No filters active 

498 

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()} 

502 

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) 

515 

516 return filtered_data 

517 

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

522