diff --git a/examples/hcs_wizard.py b/examples/hcs_wizard.py new file mode 100644 index 000000000..5427828f2 --- /dev/null +++ b/examples/hcs_wizard.py @@ -0,0 +1,32 @@ +from contextlib import suppress + +import useq +from pymmcore_plus import CMMCorePlus +from qtpy.QtWidgets import QApplication + +from pymmcore_widgets import StageWidget + +with suppress(ImportError): + from rich import print + +from pymmcore_widgets.hcs import HCSWizard + +app = QApplication([]) +mmc = CMMCorePlus.instance() +mmc.loadSystemConfiguration() +w = HCSWizard() +w.show() +w.accepted.connect(lambda: print(w.value())) +s = StageWidget("XY", mmcore=mmc) +s.show() + + +plan = useq.WellPlatePlan( + plate=useq.WellPlate.from_str("96-well"), + a1_center_xy=(1000, 1500), + rotation=0.3, + selected_wells=slice(0, 8, 2), +) +w.setValue(plan) + +app.exec() diff --git a/src/pymmcore_widgets/__init__.py b/src/pymmcore_widgets/__init__.py index 5b008d709..8a468a0f2 100644 --- a/src/pymmcore_widgets/__init__.py +++ b/src/pymmcore_widgets/__init__.py @@ -28,6 +28,7 @@ from ._shutter_widget import ShuttersWidget from ._snap_button_widget import SnapButton from ._stage_widget import StageWidget +from .hcs import HCSWizard from .hcwizard import ConfigWizard from .mda import MDAWidget from .useq_widgets import ( @@ -78,6 +79,7 @@ def __getattr__(name: str) -> object: "ExposureWidget", "GridPlanWidget", "GroupPresetTableWidget", + "HCSWizard", "ImagePreview", "InstallWidget", "LiveButton", diff --git a/src/pymmcore_widgets/hcs/__init__.py b/src/pymmcore_widgets/hcs/__init__.py index 0ebac13cb..6609c4a2c 100644 --- a/src/pymmcore_widgets/hcs/__init__.py +++ b/src/pymmcore_widgets/hcs/__init__.py @@ -1 +1,5 @@ -"""Calibration widget.""" +"""HCS Wizard.""" + +from ._hcs_wizard import HCSWizard + +__all__ = ["HCSWizard"] diff --git a/src/pymmcore_widgets/hcs/_hcs_wizard.py b/src/pymmcore_widgets/hcs/_hcs_wizard.py new file mode 100644 index 000000000..4fca96bb1 --- /dev/null +++ b/src/pymmcore_widgets/hcs/_hcs_wizard.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +import warnings +from contextlib import suppress +from pathlib import Path + +import useq +from pymmcore_plus import CMMCorePlus +from qtpy.QtCore import QSize +from qtpy.QtWidgets import QFileDialog, QVBoxLayout, QWidget, QWizard, QWizardPage +from useq import WellPlatePlan + +from pymmcore_widgets.useq_widgets import PointsPlanWidget, WellPlateWidget + +from ._plate_calibration_widget import PlateCalibrationWidget + + +class HCSWizard(QWizard): + """A wizard to setup an High Content Screening (HCS) experiment. + + This widget can be used to select a plate, calibrate it, and then select the number + of images (and their arrangement) to acquire per well. The output is a + [useq.WellPlatePlan][] object, which can be retrieved with the `value()` method. + + Parameters + ---------- + parent : QWidget | None + The parent widget. By default, None. + mmcore : CMMCorePlus | None + The CMMCorePlus instance. By default, None. + """ + + def __init__( + self, parent: QWidget | None = None, *, mmcore: CMMCorePlus | None = None + ) -> None: + super().__init__(parent) + self._mmc = mmcore or CMMCorePlus.instance() + self._calibrated: bool = False + + self.setWizardStyle(QWizard.WizardStyle.ModernStyle) + self.setWindowTitle("HCS Wizard") + + # WIZARD PAGES ---------------------- + + self.plate_page = _PlatePage(self) + self.calibration_page = _PlateCalibrationPage(self._mmc, self) + self.points_plan_page = _PointsPlanPage(self._mmc, self) + + self.addPage(self.plate_page) + self.addPage(self.calibration_page) + self.addPage(self.points_plan_page) + + # SAVE/LOAD BUTTONS ---------------------- + + # add custom button to save + self.setOption(QWizard.WizardOption.HaveCustomButton1, True) + if save_btn := self.button(QWizard.WizardButton.CustomButton1): + save_btn.setText("Save") + save_btn.clicked.connect(self.save) + save_btn.setEnabled(False) + # add custom button to load + self.setOption(QWizard.WizardOption.HaveCustomButton2, True) + if load_btn := self.button(QWizard.WizardButton.CustomButton2): + load_btn.setText("Load") + load_btn.clicked.connect(self.load) + + # CONNECTIONS --------------------------- + + self.plate_page.widget.valueChanged.connect(self._on_plate_changed) + self._on_plate_changed(self.plate_page.widget.value()) + self.calibration_page.widget.calibrationChanged.connect( + self._on_calibration_changed + ) + + def sizeHint(self) -> QSize: + return QSize(880, 690) + + def value(self) -> useq.WellPlatePlan | None: + """Return the current well plate plan, or None if the plan is uncalibrated.""" + calib_plan = self.calibration_page.widget.value() + if not self._calibrated or not calib_plan: # pragma: no cover + return None + + plate_plan = self.plate_page.widget.value() + if plate_plan.plate != calib_plan.plate: # pragma: no cover + warnings.warn("Plate Plan and Calibration Plan do not match.", stacklevel=2) + return None + + return useq.WellPlatePlan( + plate=plate_plan.plate, + selected_wells=plate_plan.selected_wells, + rotation=calib_plan.rotation, + a1_center_xy=calib_plan.a1_center_xy, + well_points_plan=self.points_plan_page.widget.value(), + ) + + def setValue(self, value: useq.WellPlatePlan) -> None: + """Set the state of the wizard to a WellPlatePlan.""" + self.plate_page.widget.setValue(value) + self.calibration_page.widget.setValue(value) + # update the points plan fov size if it's not set + point_plan = value.well_points_plan + if point_plan.fov_width is None or point_plan.fov_height is None: + point_plan.fov_width, point_plan.fov_height = ( + self.points_plan_page._get_fov_size() + ) + self.points_plan_page.widget.setValue(point_plan) + + def save(self, path: str | None = None) -> None: + """Save the current well plate plan to disk.""" + if not isinstance(path, str): + path, _ = QFileDialog.getSaveFileName( + self, "Save Well Plate Plan", "", "JSON (*.json)" + ) + elif not path.endswith(".json"): # pragma: no cover + raise ValueError("Path must end with '.json'") + if path and (value := self.value()): + txt = value.model_dump_json(exclude_unset=True, indent=2) + Path(path).write_text(txt) + + def load(self, path: str | None = None) -> None: + """Load a well plate plan from disk.""" + if not isinstance(path, str): + path, _ = QFileDialog.getOpenFileName( + self, "Load Well Plate Plan", "", "JSON (*.json)" + ) + if path: + self.setValue(WellPlatePlan.from_file(path)) + + def _on_plate_changed(self, plate_plan: useq.WellPlatePlan) -> None: + """Synchronize the points plan with the well size/shape.""" + # update the calibration widget with the new plate if it's different + current_calib_plan = self.calibration_page.widget.value() + if current_calib_plan is None or current_calib_plan.plate != plate_plan.plate: + self.calibration_page.widget.setValue(plate_plan.plate) + + pp_widget = self.points_plan_page.widget + + # set the well size on the points plan widget to the current plate well size + well_width, well_height = plate_plan.plate.well_size + pp_widget.setWellSize(well_width, well_height) + + # additionally, restrict the max width and height of the random points widget + # to the plate size minus the fov size. + fovw = pp_widget._selector.fov_w.value() + fovh = pp_widget._selector.fov_h.value() + + # if the random points shape is a rectangle, but the wells are circular, + # reduce the max width and height by 1.4 to keep the points inside the wells + random_wdg = pp_widget.random_points_wdg + if random_wdg.shape.currentText() == useq.Shape.RECTANGLE.value: + if plate_plan.plate.circular_wells: + well_width /= 1.4 + well_height /= 1.4 + + random_wdg.max_width.setMaximum(well_width * 1000) + random_wdg.max_width.setValue(well_width * 1000 - fovw / 1.4) + random_wdg.max_height.setMaximum(well_height * 1000) + random_wdg.max_height.setValue(well_height * 1000 - fovh / 1.4) + + def _on_calibration_changed(self, calibrated: bool) -> None: + self._calibrated = calibrated + self.button(QWizard.WizardButton.CustomButton1).setEnabled(calibrated) + + +# ---------------------------------- PAGES --------------------------------------- + + +class _PlatePage(QWizardPage): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + + self.setTitle("Plate and Well Selection") + + self.widget = WellPlateWidget() + self.widget.setShowRotation(False) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.widget) + + +class _PlateCalibrationPage(QWizardPage): + def __init__(self, mmcore: CMMCorePlus, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setTitle("Plate Calibration") + + self._is_complete = False + self.widget = PlateCalibrationWidget(mmcore=mmcore) + self.widget.calibrationChanged.connect(self._on_calibration_changed) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.widget) + + def isComplete(self) -> bool: + return self._is_complete + + def _on_calibration_changed(self, calibrated: bool) -> None: + self._is_complete = calibrated + self.completeChanged.emit() + + +class _PointsPlanPage(QWizardPage): + def __init__(self, mmcore: CMMCorePlus, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._mmc = mmcore + self.setTitle("Field of View Selection") + + self.widget = PointsPlanWidget() + self.widget._selector.fov_widgets.setEnabled(False) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.widget) + + self._mmc.events.pixelSizeChanged.connect(self._on_px_size_changed) + self._mmc.events.systemConfigurationLoaded.connect(self._on_px_size_changed) + self._on_px_size_changed() + + def _on_px_size_changed(self) -> None: + val = self.widget.value() + val.fov_width, val.fov_height = self._get_fov_size() + self.widget.setValue(val) + + def _get_fov_size(self) -> tuple[float, float] | tuple[None, None]: + with suppress(RuntimeError): + if self._mmc.getCameraDevice() and (px := self._mmc.getPixelSizeUm()): + return self._mmc.getImageWidth() * px, self._mmc.getImageHeight() * px + return (None, None) diff --git a/src/pymmcore_widgets/hcs/_plate_calibration_widget.py b/src/pymmcore_widgets/hcs/_plate_calibration_widget.py index f64c0133d..651cb7264 100644 --- a/src/pymmcore_widgets/hcs/_plate_calibration_widget.py +++ b/src/pymmcore_widgets/hcs/_plate_calibration_widget.py @@ -8,6 +8,7 @@ from pymmcore_plus import CMMCorePlus from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import ( + QGroupBox, QHBoxLayout, QLabel, QPushButton, @@ -44,6 +45,9 @@ def __init__( self._mmc = mmcore or CMMCorePlus.instance() self._current_plate: useq.WellPlate | None = None + self._a1_center_xy: tuple[float, float] = (0.0, 0.0) + self._well_spacing: tuple[float, float] | None = None + self._rotation: float | None = None # minimum number of wells required to be calibrated # before the plate is considered calibrated self._min_wells_required: int = 3 @@ -74,6 +78,7 @@ def __init__( self._tab_wdg.setTabEnabled(1, False) self._test_well_btn = QPushButton("Test Well", self) + self._test_well_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) self._test_well_btn.setEnabled(False) # mapping of well index (r, c) to calibration widget @@ -83,20 +88,21 @@ def __init__( self._info = QLabel("Please calibrate at least three wells.") self._info_icon = QLabel() - self._update_info(None) + self._update_info() # LAYOUT ------------------------------------------------------------- - right_layout = QVBoxLayout() - right_layout.setContentsMargins(6, 0, 0, 0) + right_wdg = QGroupBox() + right_layout = QVBoxLayout(right_wdg) + right_layout.setContentsMargins(5, 5, 5, 5) right_layout.addWidget(self._calibration_widget_stack) right_layout.addWidget(SeparatorWidget()) right_layout.addWidget(self._test_well_btn) right_layout.addStretch() top = QHBoxLayout() + top.addWidget(right_wdg, 0) top.addWidget(self._tab_wdg, 1) - top.addLayout(right_layout) info_layout = QHBoxLayout() info_layout.addWidget(self._info_icon, 0) @@ -115,12 +121,14 @@ def __init__( # ---------------------------PUBLIC API----------------------------------- - def setValue(self, plate: str | useq.WellPlate | useq.WellPlatePlan) -> None: + def setValue( + self, plate_or_plan: str | useq.WellPlate | useq.WellPlatePlan + ) -> None: """Set the plate to be calibrated. Parameters ---------- - plate : str | useq.WellPlate | useq.WellPlatePlan + plate_or_plan : str | useq.WellPlate | useq.WellPlatePlan The well plate to calibrate. If a string, it is assumed to be the name of a well plate (e.g. "96-well"). If a WellPlate instance, it is used directly. If a WellPlatePlan instance, the plate is set to the plan's plate and the @@ -128,13 +136,17 @@ def setValue(self, plate: str | useq.WellPlate | useq.WellPlatePlan) -> None: """ calibrated: bool = False plan: useq.WellPlatePlan | None = None - - if isinstance(plate, str): - plate = useq.WellPlate.from_str(plate) - elif isinstance(plate, useq.WellPlatePlan): - plan = plate - plate = plate.plate + plate: useq.WellPlate | None = None + if isinstance(plate_or_plan, str): + plate = useq.WellPlate.from_str(plate_or_plan) + elif isinstance(plate_or_plan, useq.WellPlatePlan): + self._a1_center_xy = plate_or_plan.a1_center_xy + self._rotation = plate_or_plan.rotation + plan = plate_or_plan + plate = plan.plate calibrated = True + elif isinstance(plate_or_plan, useq.WellPlate): + plate = plate_or_plan self._current_plate = plate self._plate_view.drawPlate(plate) @@ -149,26 +161,21 @@ def setValue(self, plate: str | useq.WellPlate | useq.WellPlatePlan) -> None: if calibrated and plan is not None: self._plate_test.drawPlate(plan) - self._update_info((plan.a1_center_xy, plate.well_spacing, plan.rotation)) else: self._plate_test.clear() - self._update_info(None) + self._update_info() self._tab_wdg.setTabEnabled(1, calibrated) self.calibrationChanged.emit(calibrated) def value(self) -> useq.WellPlatePlan | None: """Return the plate plan with calibration information.""" - a1_center_xy = (0.0, 0.0) - rotation: float = 0.0 - if (osr := self._origin_spacing_rotation()) is not None: - a1_center_xy, (unit_x, unit_y), rotation = osr if self._current_plate is None: # pragma: no cover return None return useq.WellPlatePlan( plate=self._current_plate, - a1_center_xy=a1_center_xy, - rotation=rotation, + a1_center_xy=self._a1_center_xy, + rotation=self._rotation, ) # ---------------------------PRIVATE API---------------------------------- @@ -321,26 +328,27 @@ def _on_well_calibration_changed(self, calibrated: bool) -> None: self._plate_view.setWellColor(*idx, None) osr = self._origin_spacing_rotation() - fully_calibrated = osr is not None - self._update_info(osr) - - if fully_calibrated and self._current_plate: - self._plate_test.drawPlate(self._current_plate) + if fully_calibrated := (osr is not None): + self._a1_center_xy, self._well_spacing, self._rotation = osr + if self._current_plate: + self._plate_test.drawPlate(self._current_plate) else: + self._a1_center_xy = (0.0, 0.0) + self._rotation = None + self._well_spacing = None self._plate_test.clear() + self._update_info() self._tab_wdg.setTabEnabled(1, fully_calibrated) self.calibrationChanged.emit(fully_calibrated) - def _update_info( - self, osr: tuple[tuple[float, float], tuple[float, float], float | None] | None - ) -> None: + def _update_info(self) -> None: style = self.style() - if osr is not None: + if self._rotation is not None: + spacing = self._well_spacing or (0, 0) txt = "Plate calibrated." ico = icon(CALIBRATED_ICON, color=GREEN) if self._current_plate is not None: - origin, spacing, rotation = osr spacing_diff = abs(spacing[0] - self._current_plate.well_spacing[0]) # if spacing is more than 5% different from the plate spacing... if spacing_diff > 0.05 * self._current_plate.well_spacing[0]: @@ -351,9 +359,10 @@ def _update_info( ) ico = style.standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning) txt += "
" - txt += f"\nA1 Center [mm]: ({origin[0]/1000:.2f}, {origin[1]/1000:.2f}), " + x0, y0 = self._a1_center_xy + txt += f"\nA1 Center [mm]: ({x0/1000:.2f}, {y0/1000:.2f}), " txt += f"Well Spacing [mm]: ({spacing[0]:.2f}, {spacing[1]:.2f}), " - txt += f"Rotation: {rotation if rotation is not None else 0}°" + txt += f"Rotation: {self._rotation}°" elif len(self._calibrated_wells) < self._min_wells_required: txt = f"Please calibrate at least {self._min_wells_required} wells." ico = style.standardIcon(QStyle.StandardPixmap.SP_MessageBoxInformation) diff --git a/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py b/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py index 4ab5d44df..31c18d706 100644 --- a/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py +++ b/src/pymmcore_widgets/useq_widgets/_well_plate_widget.py @@ -89,9 +89,11 @@ def __init__( # plate view self._view = WellPlateView(self) - self._show_rotation = QCheckBox("Show Rotation", self._view) - self._show_rotation.move(6, 6) - self._show_rotation.hide() + self._show_rotation_cb = QCheckBox("Show Rotation", self._view) + self._show_rotation_cb.setStyleSheet("background: transparent;") + self._show_rotation_cb.move(6, 6) + self._show_rotation_cb.hide() + self._show_rotation = True # LAYOUT --------------------------------------- @@ -111,12 +113,30 @@ def __init__( self._view.selectionChanged.connect(self._on_value_changed) self._clear_button.clicked.connect(self._view.clearSelection) self.plate_name.currentTextChanged.connect(self._on_plate_name_changed) - self._show_rotation.toggled.connect(self._on_show_rotation_toggled) + self._show_rotation_cb.toggled.connect(self._update_view) - self.setValue(plan or self.value()) + if plan: + self.setValue(plan) + else: + self.setValue(self.value()) # _________________________PUBLIC METHODS_________________________ # + def setShowRotation(self, allow: bool) -> None: + """Set whether to allow visible rotation of the well plate. + + If `allow` is False, the rotation checkbox is hidden and the rotation is + never shown. If True, the checkbox is shown and the user can toggle the + rotation on/off. + """ + self._show_rotation = allow + if not allow: + self._show_rotation_cb.hide() + self._show_rotation_cb.setChecked(False) + elif self._rotation: + self._show_rotation_cb.show() + self._show_rotation_cb.setChecked(True) + def value(self) -> useq.WellPlatePlan: """Return the current plate and the selected wells as a `useq.WellPlatePlan`.""" return useq.WellPlatePlan( @@ -145,14 +165,18 @@ def setValue(self, value: useq.WellPlatePlan | useq.WellPlate | Mapping) -> None self._a1_center_xy = plan.a1_center_xy with signals_blocked(self): self.plate_name.setCurrentText(plan.plate.name) - self._view.drawPlate(plan) + self._update_view(plan) - if plan.rotation: - self._show_rotation.show() - self._show_rotation.setChecked(True) + if self._show_rotation and plan.rotation: + self._show_rotation_cb.show() else: - self._show_rotation.hide() - self._show_rotation.setChecked(False) + self._show_rotation_cb.hide() + + def _update_view(self, value: bool | useq.WellPlatePlan | None = None) -> None: + rot = self._rotation if self._show_rotation_cb.isChecked() else None + plan = value if isinstance(value, useq.WellPlatePlan) else self.value() + val = plan.model_copy(update={"rotation": rot}) + self._view.drawPlate(val) def currentSelection(self) -> tuple[tuple[int, int], ...]: """Return the indices of the selected wells as `((row, col), ...)`.""" @@ -182,14 +206,11 @@ def _on_value_changed(self) -> None: self.valueChanged.emit(self.value()) def _on_plate_name_changed(self, plate_name: str) -> None: + self._view.clearSelection() plate = useq.WellPlate.from_str(plate_name) val = self.value().model_copy(update={"plate": plate, "selected_wells": None}) self.setValue(val) - - def _on_show_rotation_toggled(self, checked: bool) -> None: - rot = self._rotation if checked else None - val = self.value().model_copy(update={"rotation": rot}) - self._view.drawPlate(val) + self.valueChanged.emit(self.value()) class HoverEllipse(QGraphicsEllipseItem): @@ -204,6 +225,9 @@ def hoverEnterEvent(self, event: QGraphicsSceneHoverEvent | None) -> None: """Update color and position when hovering over the well.""" self.setCursor(Qt.CursorShape.PointingHandCursor) self.setBrush(self._selected_color) + # update tooltip font color + tooltip = self.toolTip() + self.setToolTip(f"{tooltip}") super().hoverEnterEvent(event) def hoverLeaveEvent(self, event: QGraphicsSceneHoverEvent | None) -> None: @@ -298,9 +322,7 @@ def _on_rubber_band_changed(self, rect: QRect) -> None: select.add(item) else: deselect.add(item) - with signals_blocked(self): - self._select_items(select) - self._deselect_items(deselect) + self._change_selection(select, deselect) def mousePressEvent(self, event: QMouseEvent | None) -> None: if event and event.button() == Qt.MouseButton.LeftButton: @@ -325,13 +347,13 @@ def mouseReleaseEvent(self, event: QMouseEvent | None) -> None: for item in self.items(event.pos()): if item == self._pressed_item: if self._pressed_item.data(DATA_SELECTED) is True: - self._deselect_items((self._pressed_item,)) + self._change_selection((), (self._pressed_item,)) else: if self._selection_mode == self.SelectionMode.SingleSelection: # deselect all other items - self._deselect_items(self._selected_items) + self._change_selection((), self._selected_items) if self._selection_mode != self.SelectionMode.NoSelection: - self._select_items((self._pressed_item,)) + self._change_selection((self._pressed_item,), ()) break self._pressed_item = None @@ -369,13 +391,11 @@ def setSelectedIndices(self, indices: Iterable[tuple[int, int]]) -> None: select.add(item) else: deselect.add(item) - with signals_blocked(self): - self._select_items(select) - self._deselect_items(deselect) + self._change_selection(select, deselect) def clearSelection(self) -> None: """Clear the current selection.""" - self._deselect_items(self._selected_items) + self._change_selection((), self._selected_items) def clear(self) -> None: """Clear all the wells from the view.""" @@ -492,6 +512,8 @@ def _add_preset_positions_items( new_pos = useq.Position(x=x, y=y, name=pos.name) item = HoverEllipse(edge_rect) item.setData(DATA_POSITION, new_pos) + if new_pos.x is not None and new_pos.y is not None: + item.setToolTip(f"{new_pos.name} ({new_pos.x:.0f}, {new_pos.y:.0f})") item.setZValue(1) # make sure it's on top self._scene.addItem(item) self._well_edge_spots.append(item) @@ -500,22 +522,28 @@ def _resize_to_fit(self) -> None: self.setSceneRect(self._scene.itemsBoundingRect()) self.resizeEvent(None) - def _select_items(self, items: Iterable[QAbstractGraphicsShapeItem]) -> None: - for item in items: + def _change_selection( + self, + select: Iterable[QAbstractGraphicsShapeItem], + deselect: Iterable[QAbstractGraphicsShapeItem], + ) -> None: + before = self._selected_items.copy() + + for item in select: color = item.data(DATA_COLOR) or self._selected_color item.setBrush(color) item.setData(DATA_SELECTED, True) - self._selected_items.update(items) - self.selectionChanged.emit() + self._selected_items.update(select) - def _deselect_items(self, items: Iterable[QAbstractGraphicsShapeItem]) -> None: - for item in items: + for item in deselect: if item.data(DATA_SELECTED): color = item.data(DATA_COLOR) or self._unselected_color item.setBrush(color) item.setData(DATA_SELECTED, False) - self._selected_items.difference_update(items) - self.selectionChanged.emit() + self._selected_items.difference_update(deselect) + + if before != self._selected_items: + self.selectionChanged.emit() def sizeHint(self) -> QSize: """Provide a reasonable size hint with aspect ratio of a well plate.""" diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py index 4a398e42a..4c24c3817 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_grid_row_column_widget.py @@ -36,10 +36,12 @@ def __init__(self, parent: QWidget | None = None) -> None: self.rows = QSpinBox() self.rows.setAlignment(Qt.AlignmentFlag.AlignCenter) self.rows.setMinimum(1) + self.rows.setValue(3) # columns self.columns = QSpinBox() self.columns.setAlignment(Qt.AlignmentFlag.AlignCenter) self.columns.setMinimum(1) + self.columns.setValue(3) # overlap along x self.overlap_x = QDoubleSpinBox() self.overlap_x.setAlignment(Qt.AlignmentFlag.AlignCenter) diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py index e7433c248..104744fb0 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py @@ -132,7 +132,7 @@ def __init__(self, parent: QWidget | None = None) -> None: layout.setSpacing(5) layout.addWidget(btn, 0) layout.addWidget(grpbx, 1) - main_layout.addLayout(layout) + main_layout.addLayout(layout, 1) # FOV widgets go at the bottom, and are combined into a single widget # for ease of showing/hiding the whole thing at once @@ -144,10 +144,7 @@ def __init__(self, parent: QWidget | None = None) -> None: fov_layout.addWidget(QLabel("FOV (w, h; µm):")) fov_layout.addWidget(self.fov_w) fov_layout.addWidget(self.fov_h) - main_layout.addWidget(self.fov_widgets) - - # for i in range(1, 5, 2): - # main_layout.insertWidget(i, SeparatorWidget()) + main_layout.addWidget(self.fov_widgets, 0) # _________________________PUBLIC METHODS_________________________ # diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_widget.py index 7e8d5f64e..f9a4dbba6 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_widget.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_widget.py @@ -39,6 +39,11 @@ def __init__( super().__init__(parent=parent) self._selector = RelativePointPlanSelector() + # aliases + self.single_pos_wdg = self._selector.single_pos_wdg + self.random_points_wdg = self._selector.random_points_wdg + self.grid_wdg = self._selector.grid_wdg + # graphics scene to draw the well and the fovs self._well_view = WellView() @@ -88,9 +93,9 @@ def _on_selector_value_changed(self, value: useq.RelativeMultiPointPlan) -> None self.valueChanged.emit(value) def _on_view_max_points_detected(self, value: int) -> None: - self._selector.random_points_wdg.num_points.setValue(value) + self.random_points_wdg.num_points.setValue(value) def _on_view_position_clicked(self, position: useq.RelativePosition) -> None: if self._selector.active_plan_type is useq.RandomPoints: pos_no_name = position.model_copy(update={"name": ""}) - self._selector.random_points_wdg.start_at = pos_no_name + self.random_points_wdg.start_at = pos_no_name diff --git a/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py index 8c91cee44..ee635fb49 100644 --- a/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py +++ b/src/pymmcore_widgets/useq_widgets/points_plans/_random_points_widget.py @@ -54,6 +54,7 @@ def __init__(self, parent: QWidget | None = None) -> None: self.num_points = QSpinBox() self.num_points.setAlignment(Qt.AlignmentFlag.AlignCenter) self.num_points.setRange(1, 1000) + self.num_points.setValue(10) # order combobox self.order = QComboBox() self.order.addItems([mode.value for mode in TraversalOrder]) diff --git a/tests/hcs/test_hcs_wizard.py b/tests/hcs/test_hcs_wizard.py new file mode 100644 index 000000000..0cb47c4d3 --- /dev/null +++ b/tests/hcs/test_hcs_wizard.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import useq + +from pymmcore_widgets.hcs import HCSWizard + +if TYPE_CHECKING: + from pymmcore_plus import CMMCorePlus + from pytestqt.qtbot import QtBot + + +def test_hcs_wizard(qtbot: QtBot, global_mmcore: CMMCorePlus) -> None: + """Test the HCSWizard.""" + + plan = useq.WellPlatePlan( + plate="96-well", + a1_center_xy=(1000, 1500), + rotation=0.3, + selected_wells=slice(0, 8, 2), + ) + + wdg = HCSWizard(mmcore=global_mmcore) + wdg.setValue(plan) + qtbot.addWidget(wdg) + wdg.show() + wdg.next() + wdg.next() + wdg.next() + wdg.accept() + + # we haven't done anything, the plan should be the same + assert wdg.value() == plan diff --git a/tests/hcs/test_well_plate_calibration_widget.py b/tests/hcs/test_well_plate_calibration_widget.py index 00967c8a5..53b8a0f63 100644 --- a/tests/hcs/test_well_plate_calibration_widget.py +++ b/tests/hcs/test_well_plate_calibration_widget.py @@ -5,6 +5,14 @@ from pymmcore_widgets.useq_widgets._well_plate_widget import DATA_POSITION +def test_plate_calibration_value(global_mmcore: CMMCorePlus, qtbot) -> None: + wdg = PlateCalibrationWidget(mmcore=global_mmcore) + qtbot.addWidget(wdg) + plan = useq.WellPlatePlan(plate="96-well", a1_center_xy=(10, 20), rotation=2) + wdg.setValue(plan) + assert wdg.value() == plan + + def test_plate_calibration(global_mmcore: CMMCorePlus, qtbot) -> None: wdg = PlateCalibrationWidget(mmcore=global_mmcore) wdg.show() diff --git a/tests/useq_widgets/test_useq_points_plans.py b/tests/useq_widgets/test_useq_points_plans.py index 65533cd2c..e5037ae4f 100644 --- a/tests/useq_widgets/test_useq_points_plans.py +++ b/tests/useq_widgets/test_useq_points_plans.py @@ -45,7 +45,7 @@ def test_random_points_widget(qtbot: QtBot) -> None: wdg = pp.RandomPointWidget() qtbot.addWidget(wdg) - assert wdg.num_points.value() == 1 + assert wdg.num_points.value() == 10 assert wdg.max_width.value() == 6000 assert wdg.max_height.value() == 6000 assert wdg.shape.currentText() == "ellipse" @@ -69,8 +69,8 @@ def test_random_points_widget(qtbot: QtBot) -> None: def test_grid_plan_widget(qtbot: QtBot) -> None: wdg = pp.GridRowColumnWidget() qtbot.addWidget(wdg) - assert wdg.rows.value() == 1 - assert wdg.columns.value() == 1 + assert wdg.rows.value() == 3 + assert wdg.columns.value() == 3 assert wdg.overlap_x.value() == 0 assert wdg.overlap_y.value() == 0 assert wdg.mode.currentText() == "row_wise_snake" @@ -199,7 +199,7 @@ def test_max_points_detected(qtbot: QtBot) -> None: qtbot.addWidget(wdg) with qtbot.waitSignal(wdg._well_view.maxPointsDetected): - wdg._selector.random_points_wdg.num_points.setValue(100) + wdg.random_points_wdg.num_points.setValue(100) assert wdg.value().num_points < 60