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