Coverage for openhcs/pyqt_gui/widgets/shared/plate_view_widget.py: 0.0%

324 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-04 02:09 +0000

1""" 

2Plate View Widget - Visual grid representation of plate wells. 

3 

4Displays a clickable grid of wells (e.g., A01-H12 for 96-well plate) with visual 

5states for empty/has-images/selected. Supports multi-select and subdirectory selection. 

6""" 

7 

8import logging 

9from typing import Set, List, Optional, Tuple 

10from PyQt6.QtWidgets import ( 

11 QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QPushButton, 

12 QLabel, QFrame, QButtonGroup 

13) 

14from PyQt6.QtCore import Qt, pyqtSignal 

15from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme 

16from openhcs.pyqt_gui.shared.style_generator import StyleSheetGenerator 

17 

18logger = logging.getLogger(__name__) 

19 

20 

21class PlateViewWidget(QWidget): 

22 """ 

23 Visual plate grid widget with clickable wells. 

24 

25 Features: 

26 - Auto-detects plate dimensions from well IDs 

27 - Clickable well buttons with visual states (empty/has-images/selected) 

28 - Multi-select support (Ctrl+Click, Shift+Click) 

29 - Subdirectory selector for multiple plate outputs 

30 - Clear selection button 

31 - Detachable to external window 

32 

33 Signals: 

34 wells_selected: Emitted when well selection changes (set of well IDs) 

35 detach_requested: Emitted when user clicks detach button 

36 """ 

37 

38 wells_selected = pyqtSignal(set) 

39 detach_requested = pyqtSignal() 

40 

41 def __init__(self, color_scheme: Optional[PyQt6ColorScheme] = None, parent=None): 

42 super().__init__(parent) 

43 

44 self.color_scheme = color_scheme or PyQt6ColorScheme() 

45 self.style_gen = StyleSheetGenerator(self.color_scheme) 

46 

47 # State 

48 self.well_buttons = {} # well_id -> QPushButton 

49 self.wells_with_images = set() # Set of well IDs that have images 

50 self.selected_wells = set() # Currently selected wells 

51 self.plate_dimensions = (8, 12) # rows, cols (default 96-well) 

52 self.subdirs = [] # List of subdirectory names 

53 self.active_subdir = None # Currently selected subdirectory 

54 self.coord_to_well = {} # (row_index, col_index) -> well_id mapping 

55 self.well_to_coord = {} # well_id -> (row_index, col_index) reverse mapping 

56 

57 # Drag selection state 

58 self.is_dragging = False 

59 self.drag_start_well = None 

60 self.drag_current_well = None 

61 self.drag_selection_mode = None # 'select' or 'deselect' 

62 self.drag_affected_wells = set() # Wells affected by current drag operation 

63 self.pre_drag_selection = set() # Selection state before drag started 

64 

65 # Column filter sync 

66 self.well_filter_widget = None # Reference to ColumnFilterWidget for 'well' column 

67 

68 # UI components 

69 self.subdir_buttons = {} # subdir_name -> QPushButton 

70 self.subdir_button_group = None 

71 self.well_grid_layout = None 

72 self.status_label = None 

73 

74 self._setup_ui() 

75 

76 def _setup_ui(self): 

77 """Setup the UI layout.""" 

78 layout = QVBoxLayout(self) 

79 layout.setContentsMargins(5, 5, 5, 5) 

80 layout.setSpacing(5) 

81 

82 # Header with title, detach button, and clear button 

83 header_layout = QHBoxLayout() 

84 title_label = QLabel("Plate View") 

85 title_label.setStyleSheet(f"font-weight: bold; color: {self.color_scheme.to_hex(self.color_scheme.text_accent)};") 

86 header_layout.addWidget(title_label) 

87 

88 header_layout.addStretch() 

89 

90 detach_btn = QPushButton("↗") 

91 detach_btn.setToolTip("Detach to separate window") 

92 detach_btn.setFixedWidth(30) 

93 detach_btn.setStyleSheet(self.style_gen.generate_button_style()) 

94 detach_btn.clicked.connect(lambda: self.detach_requested.emit()) 

95 header_layout.addWidget(detach_btn) 

96 

97 clear_btn = QPushButton("Clear Selection") 

98 clear_btn.setStyleSheet(self.style_gen.generate_button_style()) 

99 clear_btn.clicked.connect(self.clear_selection) 

100 header_layout.addWidget(clear_btn) 

101 

102 layout.addLayout(header_layout) 

103 

104 # Subdirectory selector (initially hidden) 

105 self.subdir_frame = QFrame() 

106 self.subdir_layout = QHBoxLayout(self.subdir_frame) 

107 self.subdir_layout.setContentsMargins(0, 0, 0, 0) 

108 self.subdir_layout.setSpacing(5) 

109 

110 subdir_label = QLabel("Plate Output:") 

111 self.subdir_layout.addWidget(subdir_label) 

112 

113 self.subdir_button_group = QButtonGroup(self) 

114 self.subdir_button_group.setExclusive(True) 

115 

116 self.subdir_layout.addStretch() 

117 self.subdir_frame.setVisible(False) 

118 layout.addWidget(self.subdir_frame) 

119 

120 # Well grid container 

121 grid_container = QFrame() 

122 grid_container.setStyleSheet(f"background-color: {self.color_scheme.to_hex(self.color_scheme.panel_bg)}; border-radius: 3px;") 

123 grid_layout_wrapper = QVBoxLayout(grid_container) 

124 grid_layout_wrapper.setContentsMargins(10, 10, 10, 10) 

125 

126 # Create a centered widget for the grid 

127 grid_center_widget = QWidget() 

128 grid_center_layout = QHBoxLayout(grid_center_widget) 

129 grid_center_layout.setContentsMargins(0, 0, 0, 0) 

130 

131 # Add stretches to center the grid 

132 grid_center_layout.addStretch() 

133 

134 # Grid widget with mouse tracking for drag selection 

135 grid_widget = QWidget() 

136 grid_widget.setMouseTracking(True) 

137 self.well_grid_layout = QGridLayout(grid_widget) 

138 self.well_grid_layout.setSpacing(3) # Slightly more spacing 

139 self.well_grid_layout.setContentsMargins(0, 0, 0, 0) 

140 self.grid_widget = grid_widget # Store reference 

141 

142 grid_center_layout.addWidget(grid_widget) 

143 grid_center_layout.addStretch() 

144 

145 grid_layout_wrapper.addWidget(grid_center_widget) 

146 

147 layout.addWidget(grid_container, 1) # Stretch to fill 

148 

149 # Status label 

150 self.status_label = QLabel("No wells") 

151 self.status_label.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_secondary)};") 

152 layout.addWidget(self.status_label) 

153 

154 # Install event filter on grid widget for drag selection 

155 self.grid_widget.installEventFilter(self) 

156 

157 def set_subdirectories(self, subdirs: List[str]): 

158 """ 

159 Set available subdirectories for plate outputs. 

160  

161 Args: 

162 subdirs: List of subdirectory names 

163 """ 

164 self.subdirs = subdirs 

165 

166 # Clear existing buttons 

167 for btn in self.subdir_buttons.values(): 

168 self.subdir_button_group.removeButton(btn) 

169 btn.deleteLater() 

170 self.subdir_buttons.clear() 

171 

172 if len(subdirs) == 0: 

173 # No subdirs, hide selector 

174 self.subdir_frame.setVisible(False) 

175 self.active_subdir = None 

176 elif len(subdirs) == 1: 

177 # Single subdir, auto-select and hide selector 

178 self.subdir_frame.setVisible(False) 

179 self.active_subdir = subdirs[0] 

180 else: 

181 # Multiple subdirs, show selector 

182 self.subdir_frame.setVisible(True) 

183 

184 # Create button for each subdir 

185 for subdir in subdirs: 

186 btn = QPushButton(subdir) 

187 btn.setCheckable(True) 

188 btn.setStyleSheet(self.style_gen.generate_button_style()) 

189 btn.clicked.connect(lambda checked, s=subdir: self._on_subdir_selected(s)) 

190 

191 self.subdir_button_group.addButton(btn) 

192 self.subdir_layout.insertWidget(self.subdir_layout.count() - 1, btn) # Before stretch 

193 self.subdir_buttons[subdir] = btn 

194 

195 # Auto-select first subdir 

196 if subdirs: 

197 first_btn = self.subdir_buttons[subdirs[0]] 

198 first_btn.setChecked(True) 

199 self.active_subdir = subdirs[0] 

200 

201 def _on_subdir_selected(self, subdir: str): 

202 """Handle subdirectory selection.""" 

203 self.active_subdir = subdir 

204 # Could emit signal here if needed for filtering by subdir 

205 

206 def set_available_wells(self, well_ids: Set[str], plate_dimensions: Optional[Tuple[int, int]] = None, 

207 coord_to_well: Optional[dict] = None): 

208 """ 

209 Update which wells have images and rebuild grid. 

210 

211 Args: 

212 well_ids: Set of well IDs that have images 

213 plate_dimensions: Optional (rows, cols) tuple. If None, auto-detects from well IDs. 

214 coord_to_well: Optional mapping from (row_index, col_index) to well_id. 

215 Required for non-standard well ID formats (e.g., Opera Phenix R01C01). 

216 """ 

217 self.wells_with_images = well_ids 

218 self.coord_to_well = coord_to_well or {} 

219 

220 # Build reverse mapping (well_id -> coord) 

221 self.well_to_coord = {well_id: coord for coord, well_id in self.coord_to_well.items()} 

222 

223 if not well_ids: 

224 self._clear_grid() 

225 self.status_label.setText("No wells") 

226 return 

227 

228 # Use provided dimensions or auto-detect 

229 if plate_dimensions is not None: 

230 self.plate_dimensions = plate_dimensions 

231 else: 

232 self.plate_dimensions = self._detect_dimensions(well_ids) 

233 

234 # Rebuild grid 

235 self._build_grid() 

236 

237 # Update status 

238 self._update_status() 

239 

240 def _detect_dimensions(self, well_ids: Set[str]) -> Tuple[int, int]: 

241 """ 

242 Auto-detect plate dimensions from well IDs. 

243  

244 Assumes well IDs are already parsed (e.g., 'A01', 'B03'). 

245 Extracts max row letter and max column number. 

246  

247 Args: 

248 well_ids: Set of well IDs 

249  

250 Returns: 

251 (rows, cols) tuple 

252 """ 

253 max_row = 0 

254 max_col = 0 

255 

256 for well_id in well_ids: 

257 # Extract row letter(s) and column number(s) 

258 row_part = ''.join(c for c in well_id if c.isalpha()) 

259 col_part = ''.join(c for c in well_id if c.isdigit()) 

260 

261 if row_part: 

262 # Convert row letter to index (A=1, B=2, AA=27, etc.) 

263 row_idx = sum((ord(c.upper()) - ord('A') + 1) * (26 ** i) 

264 for i, c in enumerate(reversed(row_part))) 

265 max_row = max(max_row, row_idx) 

266 

267 if col_part: 

268 max_col = max(max_col, int(col_part)) 

269 

270 return (max_row, max_col) 

271 

272 def _clear_grid(self): 

273 """Clear the well grid.""" 

274 for btn in self.well_buttons.values(): 

275 btn.deleteLater() 

276 self.well_buttons.clear() 

277 

278 # Clear layout 

279 while self.well_grid_layout.count(): 

280 item = self.well_grid_layout.takeAt(0) 

281 if item.widget(): 

282 item.widget().deleteLater() 

283 

284 def _build_grid(self): 

285 """Build the well grid based on current dimensions.""" 

286 self._clear_grid() 

287 

288 rows, cols = self.plate_dimensions 

289 

290 # Add column headers (1, 2, 3, ...) 

291 for col in range(1, cols + 1): 

292 header = QLabel(str(col)) 

293 header.setAlignment(Qt.AlignmentFlag.AlignCenter) 

294 header.setFixedSize(40, 20) # Fixed size for consistent spacing 

295 header.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_secondary)}; font-size: 11px;") 

296 self.well_grid_layout.addWidget(header, 0, col) 

297 

298 # Add row headers and well buttons 

299 for row in range(1, rows + 1): 

300 # Row header (A, B, C, ...) 

301 row_letter = self._index_to_row_letter(row) 

302 header = QLabel(row_letter) 

303 header.setAlignment(Qt.AlignmentFlag.AlignCenter) 

304 header.setFixedSize(20, 40) # Fixed size for consistent spacing 

305 header.setStyleSheet(f"color: {self.color_scheme.to_hex(self.color_scheme.text_secondary)}; font-size: 11px;") 

306 self.well_grid_layout.addWidget(header, row, 0) 

307 

308 # Well buttons 

309 for col in range(1, cols + 1): 

310 # Use coordinate mapping if available, otherwise construct standard well ID 

311 well_id = self.coord_to_well.get((row, col), f"{row_letter}{col:02d}") 

312 

313 btn = QPushButton() 

314 btn.setFixedSize(40, 40) # Larger buttons for better visibility 

315 btn.setCheckable(True) 

316 

317 # Set initial state 

318 if well_id in self.wells_with_images: 

319 btn.setEnabled(True) 

320 btn.setStyleSheet(self._get_well_button_style('has_images')) 

321 else: 

322 btn.setEnabled(False) 

323 btn.setStyleSheet(self._get_well_button_style('empty')) 

324 

325 btn.clicked.connect(lambda checked, wid=well_id: self._on_well_clicked(wid, checked)) 

326 

327 # Store well_id in button for lookup 

328 btn.setProperty('well_id', well_id) 

329 

330 self.well_grid_layout.addWidget(btn, row, col) 

331 self.well_buttons[well_id] = btn 

332 

333 # Update reverse mapping if not using coord_to_well 

334 if (row, col) not in self.coord_to_well: 

335 self.well_to_coord[well_id] = (row, col) 

336 

337 def _index_to_row_letter(self, index: int) -> str: 

338 """Convert row index to letter(s) (1=A, 2=B, 27=AA, etc.).""" 

339 result = "" 

340 while index > 0: 

341 index -= 1 

342 result = chr(ord('A') + (index % 26)) + result 

343 index //= 26 

344 return result 

345 

346 def _get_well_button_style(self, state: str) -> str: 

347 """Generate style for well button based on state.""" 

348 cs = self.color_scheme 

349 

350 if state == 'empty': 

351 return f""" 

352 QPushButton {{ 

353 background-color: {cs.to_hex(cs.button_disabled_bg)}; 

354 color: {cs.to_hex(cs.button_disabled_text)}; 

355 border: none; 

356 border-radius: 3px; 

357 }} 

358 """ 

359 elif state == 'selected': 

360 return f""" 

361 QPushButton {{ 

362 background-color: {cs.to_hex(cs.selection_bg)}; 

363 color: {cs.to_hex(cs.selection_text)}; 

364 border: 2px solid {cs.to_hex(cs.border_color)}; 

365 border-radius: 3px; 

366 }} 

367 """ 

368 else: # has_images 

369 return f""" 

370 QPushButton {{ 

371 background-color: {cs.to_hex(cs.button_normal_bg)}; 

372 color: {cs.to_hex(cs.button_text)}; 

373 border: none; 

374 border-radius: 3px; 

375 }} 

376 QPushButton:hover {{ 

377 background-color: {cs.to_hex(cs.button_hover_bg)}; 

378 }} 

379 """ 

380 

381 def _on_well_clicked(self, well_id: str, checked: bool): 

382 """Handle well button click (only for non-drag clicks).""" 

383 # Skip if this was part of a drag operation 

384 if self.is_dragging: 

385 return 

386 

387 if checked: 

388 self.selected_wells.add(well_id) 

389 self.well_buttons[well_id].setStyleSheet(self._get_well_button_style('selected')) 

390 else: 

391 self.selected_wells.discard(well_id) 

392 self.well_buttons[well_id].setStyleSheet(self._get_well_button_style('has_images')) 

393 

394 self._update_status() 

395 self.wells_selected.emit(self.selected_wells.copy()) 

396 self.sync_to_well_filter() 

397 

398 def clear_selection(self, emit_signal: bool = True, sync_to_filter: bool = True): 

399 """ 

400 Clear all selected wells. 

401 

402 Args: 

403 emit_signal: Whether to emit wells_selected signal (default True) 

404 sync_to_filter: Whether to sync to well filter (default True) 

405 """ 

406 for well_id in list(self.selected_wells): 

407 if well_id in self.well_buttons: 

408 btn = self.well_buttons[well_id] 

409 btn.setChecked(False) 

410 btn.setStyleSheet(self._get_well_button_style('has_images')) 

411 

412 self.selected_wells.clear() 

413 self._update_status() 

414 

415 if emit_signal: 

416 self.wells_selected.emit(set()) 

417 

418 if sync_to_filter: 

419 self.sync_to_well_filter() 

420 

421 def select_wells(self, well_ids: Set[str], emit_signal: bool = True): 

422 """ 

423 Programmatically select wells. 

424 

425 Args: 

426 well_ids: Set of well IDs to select 

427 emit_signal: Whether to emit wells_selected signal (default True) 

428 """ 

429 # Clear without syncing to filter (we'll sync after setting new selection) 

430 self.clear_selection(emit_signal=False, sync_to_filter=False) 

431 

432 for well_id in well_ids: 

433 if well_id in self.well_buttons and well_id in self.wells_with_images: 

434 self.selected_wells.add(well_id) 

435 btn = self.well_buttons[well_id] 

436 btn.setChecked(True) 

437 btn.setStyleSheet(self._get_well_button_style('selected')) 

438 

439 self._update_status() 

440 if emit_signal: 

441 self.wells_selected.emit(self.selected_wells.copy()) 

442 self.sync_to_well_filter() 

443 

444 def _update_status(self): 

445 """Update status label.""" 

446 total_wells = len(self.wells_with_images) 

447 selected_count = len(self.selected_wells) 

448 

449 if selected_count > 0: 

450 self.status_label.setText( 

451 f"{total_wells} wells have images | {selected_count} selected" 

452 ) 

453 else: 

454 self.status_label.setText(f"{total_wells} wells have images") 

455 

456 def eventFilter(self, obj, event): 

457 """Handle mouse events on grid widget for drag selection.""" 

458 if obj != self.grid_widget: 

459 return super().eventFilter(obj, event) 

460 

461 from PyQt6.QtCore import QEvent 

462 

463 if event.type() == QEvent.Type.MouseButtonPress: 

464 if event.button() == Qt.MouseButton.LeftButton: 

465 # Find which button is under the cursor 

466 child = self.grid_widget.childAt(event.pos()) 

467 if isinstance(child, QPushButton): 

468 well_id = child.property('well_id') 

469 if well_id and well_id in self.wells_with_images: 

470 # Start drag selection - save current selection state 

471 self.is_dragging = True 

472 self.drag_start_well = well_id 

473 self.drag_current_well = well_id 

474 self.drag_affected_wells = set() 

475 self.pre_drag_selection = self.selected_wells.copy() 

476 

477 # Determine selection mode 

478 self.drag_selection_mode = 'deselect' if well_id in self.selected_wells else 'select' 

479 

480 # Apply to starting well 

481 self._toggle_well_selection(well_id, self.drag_selection_mode == 'select') 

482 self.drag_affected_wells.add(well_id) 

483 

484 # Emit signal immediately 

485 self._update_status() 

486 self.wells_selected.emit(self.selected_wells.copy()) 

487 self.sync_to_well_filter() 

488 

489 elif event.type() == QEvent.Type.MouseMove: 

490 if self.is_dragging and event.buttons() & Qt.MouseButton.LeftButton: 

491 # Find which button is under the cursor 

492 child = self.grid_widget.childAt(event.pos()) 

493 if isinstance(child, QPushButton): 

494 well_id = child.property('well_id') 

495 if well_id and well_id in self.wells_with_images: 

496 if well_id != self.drag_current_well: 

497 self.drag_current_well = well_id 

498 self._update_drag_selection() 

499 

500 elif event.type() == QEvent.Type.MouseButtonRelease: 

501 if event.button() == Qt.MouseButton.LeftButton and self.is_dragging: 

502 # End drag selection 

503 self.is_dragging = False 

504 self.drag_start_well = None 

505 self.drag_current_well = None 

506 self.drag_selection_mode = None 

507 self.drag_affected_wells.clear() 

508 

509 return super().eventFilter(obj, event) 

510 

511 def _toggle_well_selection(self, well_id: str, select: bool): 

512 """Toggle selection state of a single well.""" 

513 if well_id not in self.well_buttons or well_id not in self.wells_with_images: 

514 return 

515 

516 btn = self.well_buttons[well_id] 

517 

518 if select and well_id not in self.selected_wells: 

519 self.selected_wells.add(well_id) 

520 btn.setChecked(True) 

521 btn.setStyleSheet(self._get_well_button_style('selected')) 

522 elif not select and well_id in self.selected_wells: 

523 self.selected_wells.discard(well_id) 

524 btn.setChecked(False) 

525 btn.setStyleSheet(self._get_well_button_style('has_images')) 

526 

527 def _update_drag_selection(self): 

528 """Update selection for all wells in the drag rectangle.""" 

529 if not self.drag_start_well or not self.drag_current_well: 

530 return 

531 

532 # Get coordinates 

533 start_coord = self.well_to_coord.get(self.drag_start_well) 

534 current_coord = self.well_to_coord.get(self.drag_current_well) 

535 

536 if not start_coord or not current_coord: 

537 return 

538 

539 # Calculate rectangle bounds 

540 min_row = min(start_coord[0], current_coord[0]) 

541 max_row = max(start_coord[0], current_coord[0]) 

542 min_col = min(start_coord[1], current_coord[1]) 

543 max_col = max(start_coord[1], current_coord[1]) 

544 

545 # Find all wells in current rectangle 

546 wells_in_rectangle = set() 

547 for row in range(min_row, max_row + 1): 

548 for col in range(min_col, max_col + 1): 

549 well_id = self.coord_to_well.get((row, col)) 

550 if well_id and well_id in self.wells_with_images: 

551 wells_in_rectangle.add(well_id) 

552 

553 # Revert wells that were affected by previous drag but are no longer in rectangle 

554 # Restore them to their pre-drag state 

555 wells_to_revert = self.drag_affected_wells - wells_in_rectangle 

556 for well_id in wells_to_revert: 

557 was_selected_before_drag = well_id in self.pre_drag_selection 

558 self._toggle_well_selection(well_id, was_selected_before_drag) 

559 

560 # Apply selection to all wells in current rectangle 

561 for well_id in wells_in_rectangle: 

562 self._toggle_well_selection(well_id, self.drag_selection_mode == 'select') 

563 

564 # Update affected wells to current rectangle 

565 self.drag_affected_wells = wells_in_rectangle.copy() 

566 

567 # Emit signal and sync to well filter 

568 self._update_status() 

569 self.wells_selected.emit(self.selected_wells.copy()) 

570 self.sync_to_well_filter() 

571 

572 def set_well_filter_widget(self, well_filter_widget): 

573 """ 

574 Set reference to the well column filter widget for bidirectional sync. 

575 

576 Args: 

577 well_filter_widget: ColumnFilterWidget instance for the 'well' column 

578 """ 

579 self.well_filter_widget = well_filter_widget 

580 

581 def sync_to_well_filter(self): 

582 """Sync current plate view selection to well filter checkboxes.""" 

583 if not self.well_filter_widget: 

584 return 

585 

586 # Update well filter checkboxes to match plate view selection 

587 # Block signals to prevent circular sync loop 

588 # If no wells selected in plate view, select all in filter (show all) 

589 if self.selected_wells: 

590 self.well_filter_widget.set_selected_values(self.selected_wells, block_signals=True) 

591 else: 

592 self.well_filter_widget.select_all(block_signals=True) 

593 

594 def sync_from_well_filter(self): 

595 """Sync well filter checkbox selection to plate view.""" 

596 if not self.well_filter_widget: 

597 return 

598 

599 # Get selected wells from filter 

600 selected_in_filter = self.well_filter_widget.get_selected_values() 

601 

602 # Update plate view to match (without emitting signal to avoid loop) 

603 self.select_wells(selected_in_filter, emit_signal=False) 

604