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
« 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.
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"""
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
18logger = logging.getLogger(__name__)
21class PlateViewWidget(QWidget):
22 """
23 Visual plate grid widget with clickable wells.
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
33 Signals:
34 wells_selected: Emitted when well selection changes (set of well IDs)
35 detach_requested: Emitted when user clicks detach button
36 """
38 wells_selected = pyqtSignal(set)
39 detach_requested = pyqtSignal()
41 def __init__(self, color_scheme: Optional[PyQt6ColorScheme] = None, parent=None):
42 super().__init__(parent)
44 self.color_scheme = color_scheme or PyQt6ColorScheme()
45 self.style_gen = StyleSheetGenerator(self.color_scheme)
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
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
65 # Column filter sync
66 self.well_filter_widget = None # Reference to ColumnFilterWidget for 'well' column
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
74 self._setup_ui()
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)
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)
88 header_layout.addStretch()
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)
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)
102 layout.addLayout(header_layout)
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)
110 subdir_label = QLabel("Plate Output:")
111 self.subdir_layout.addWidget(subdir_label)
113 self.subdir_button_group = QButtonGroup(self)
114 self.subdir_button_group.setExclusive(True)
116 self.subdir_layout.addStretch()
117 self.subdir_frame.setVisible(False)
118 layout.addWidget(self.subdir_frame)
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)
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)
131 # Add stretches to center the grid
132 grid_center_layout.addStretch()
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
142 grid_center_layout.addWidget(grid_widget)
143 grid_center_layout.addStretch()
145 grid_layout_wrapper.addWidget(grid_center_widget)
147 layout.addWidget(grid_container, 1) # Stretch to fill
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)
154 # Install event filter on grid widget for drag selection
155 self.grid_widget.installEventFilter(self)
157 def set_subdirectories(self, subdirs: List[str]):
158 """
159 Set available subdirectories for plate outputs.
161 Args:
162 subdirs: List of subdirectory names
163 """
164 self.subdirs = subdirs
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()
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)
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))
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
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]
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
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.
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 {}
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()}
223 if not well_ids:
224 self._clear_grid()
225 self.status_label.setText("No wells")
226 return
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)
234 # Rebuild grid
235 self._build_grid()
237 # Update status
238 self._update_status()
240 def _detect_dimensions(self, well_ids: Set[str]) -> Tuple[int, int]:
241 """
242 Auto-detect plate dimensions from well IDs.
244 Assumes well IDs are already parsed (e.g., 'A01', 'B03').
245 Extracts max row letter and max column number.
247 Args:
248 well_ids: Set of well IDs
250 Returns:
251 (rows, cols) tuple
252 """
253 max_row = 0
254 max_col = 0
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())
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)
267 if col_part:
268 max_col = max(max_col, int(col_part))
270 return (max_row, max_col)
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()
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()
284 def _build_grid(self):
285 """Build the well grid based on current dimensions."""
286 self._clear_grid()
288 rows, cols = self.plate_dimensions
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)
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)
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}")
313 btn = QPushButton()
314 btn.setFixedSize(40, 40) # Larger buttons for better visibility
315 btn.setCheckable(True)
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'))
325 btn.clicked.connect(lambda checked, wid=well_id: self._on_well_clicked(wid, checked))
327 # Store well_id in button for lookup
328 btn.setProperty('well_id', well_id)
330 self.well_grid_layout.addWidget(btn, row, col)
331 self.well_buttons[well_id] = btn
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)
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
346 def _get_well_button_style(self, state: str) -> str:
347 """Generate style for well button based on state."""
348 cs = self.color_scheme
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 """
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
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'))
394 self._update_status()
395 self.wells_selected.emit(self.selected_wells.copy())
396 self.sync_to_well_filter()
398 def clear_selection(self, emit_signal: bool = True, sync_to_filter: bool = True):
399 """
400 Clear all selected wells.
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'))
412 self.selected_wells.clear()
413 self._update_status()
415 if emit_signal:
416 self.wells_selected.emit(set())
418 if sync_to_filter:
419 self.sync_to_well_filter()
421 def select_wells(self, well_ids: Set[str], emit_signal: bool = True):
422 """
423 Programmatically select wells.
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)
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'))
439 self._update_status()
440 if emit_signal:
441 self.wells_selected.emit(self.selected_wells.copy())
442 self.sync_to_well_filter()
444 def _update_status(self):
445 """Update status label."""
446 total_wells = len(self.wells_with_images)
447 selected_count = len(self.selected_wells)
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")
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)
461 from PyQt6.QtCore import QEvent
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()
477 # Determine selection mode
478 self.drag_selection_mode = 'deselect' if well_id in self.selected_wells else 'select'
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)
484 # Emit signal immediately
485 self._update_status()
486 self.wells_selected.emit(self.selected_wells.copy())
487 self.sync_to_well_filter()
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()
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()
509 return super().eventFilter(obj, event)
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
516 btn = self.well_buttons[well_id]
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'))
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
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)
536 if not start_coord or not current_coord:
537 return
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])
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)
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)
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')
564 # Update affected wells to current rectangle
565 self.drag_affected_wells = wells_in_rectangle.copy()
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()
572 def set_well_filter_widget(self, well_filter_widget):
573 """
574 Set reference to the well column filter widget for bidirectional sync.
576 Args:
577 well_filter_widget: ColumnFilterWidget instance for the 'well' column
578 """
579 self.well_filter_widget = well_filter_widget
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
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)
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
599 # Get selected wells from filter
600 selected_in_filter = self.well_filter_widget.get_selected_values()
602 # Update plate view to match (without emitting signal to avoid loop)
603 self.select_wells(selected_in_filter, emit_signal=False)