Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Points plan selector #315

Merged
merged 23 commits into from
Jul 7, 2024
Merged
3 changes: 2 additions & 1 deletion src/pymmcore_widgets/useq_widgets/points_plans/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Widgets that create MultiPoint plans."""

from ._grid_row_column_widget import GridRowColumnWidget
from ._points_plan_selector import RelativePointPlanSelector
from ._random_points_widget import RandomPointWidget

__all__ = ["GridRowColumnWidget", "RandomPointWidget"]
__all__ = ["GridRowColumnWidget", "RandomPointWidget", "RelativePointPlanSelector"]
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
self._relative_to: str = "center"

# title
title = QLabel(text="Fields of View in a Grid.")
title = QLabel(text="Fields of View in a Grid")
title.setStyleSheet("font-weight: bold;")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
from __future__ import annotations

from typing import TYPE_CHECKING

import useq
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import (
QButtonGroup,
QGroupBox,
QHBoxLayout,
QLabel,
QRadioButton,
QVBoxLayout,
QWidget,
)
from superqt.utils import signals_blocked

from pymmcore_widgets._util import SeparatorWidget

from ._grid_row_column_widget import GridRowColumnWidget
from ._random_points_widget import RandomPointWidget

if TYPE_CHECKING:
from typing_extensions import TypeAlias

# excluding useq.GridWidthHeight even though it's also a relative multi point plan
RelativePointPlan: TypeAlias = (
useq.GridRowsColumns | useq.RandomPoints | useq.RelativePosition
)


class RelativePositionWidget(QWidget):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
title = QLabel("Single FOV")
title.setStyleSheet("font-weight: bold;")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(title)

def value(self) -> useq.RelativePosition:
return useq.RelativePosition()

def setValue(self, plan: useq.RelativePosition) -> None:
pass

Check warning on line 46 in src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py#L46

Added line #L46 was not covered by tests


class RelativePointPlanSelector(QWidget):
"""Widget to select a relative multi-position point plan.

In useq, a RelativeMultiPointPlan is one of:
- useq.RelativePosition
- useq.RandomPoints
- useq.GridRowsColumns
- useq.GridWidthHeight # not included in this widget
"""

valueChanged = Signal(object)

def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent=parent)

# WIDGET ----------------------

# plan widgets
self.single_pos_wdg = RelativePositionWidget()
self.random_points_wdg = RandomPointWidget()
self.grid_wdg = GridRowColumnWidget()

# this gets changed when the radio buttons are toggled
self._active_plan_widget: (
RelativePositionWidget | RandomPointWidget | GridRowColumnWidget
) = self.single_pos_wdg

# radio buttons selection

self.single_radio_btn = QRadioButton()
self.single_radio_btn.setChecked(True)
self.random_radio_btn = QRadioButton()
self.grid_radio_btn = QRadioButton()

self._mode_btn_group = QButtonGroup()
self._mode_btn_group.addButton(self.single_radio_btn)
self._mode_btn_group.addButton(self.random_radio_btn)
self._mode_btn_group.addButton(self.grid_radio_btn)

# CONNECTIONS ----------------------

self._mode_btn_group.buttonToggled.connect(self._on_radiobutton_toggled)
self.random_points_wdg.valueChanged.connect(self._on_value_changed)
self.grid_wdg.valueChanged.connect(self._on_value_changed)

# LAYOUT ----------------------

main_layout = QVBoxLayout(self)
main_layout.setSpacing(10)
main_layout.setContentsMargins(10, 10, 10, 10)
for btn, wdg in (
(self.single_radio_btn, self.single_pos_wdg),
(self.random_radio_btn, self.random_points_wdg),
(self.grid_radio_btn, self.grid_wdg),
):
wdg.setEnabled(btn.isChecked()) # type: ignore [attr-defined]
grpbx = QGroupBox()
grpbx.setLayout(QVBoxLayout())
grpbx.layout().addWidget(wdg)

layout = QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
layout.addWidget(btn, 0)
layout.addWidget(grpbx, 1)
main_layout.addLayout(layout)

for i in range(0, 7, 2):
main_layout.insertWidget(i, SeparatorWidget())

# _________________________PUBLIC METHODS_________________________ #

def value(self) -> useq.RelativeMultiPointPlan:
return self._active_plan_widget.value()

def setValue(self, plan: useq.RelativeMultiPointPlan) -> None:
"""Set the value of the widget.

Parameters
----------
plan : useq.RelativePosition | useq.RandomPoints | useq.GridRowsColumns
The point plan to set.
"""
if isinstance(plan, useq.RandomPoints):
with signals_blocked(self.random_points_wdg):
self.random_points_wdg.setValue(plan)
self.random_radio_btn.setChecked(True)
elif isinstance(plan, useq.GridRowsColumns):
with signals_blocked(self.grid_wdg):
self.grid_wdg.setValue(plan)
self.grid_radio_btn.setChecked(True)
elif isinstance(plan, useq.RelativePosition):
with signals_blocked(self.single_pos_wdg):
self.single_pos_wdg.setValue(plan)
self.single_radio_btn.setChecked(True)

Check warning on line 143 in src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py#L140-L143

Added lines #L140 - L143 were not covered by tests
else: # pragma: no cover
raise ValueError(f"Invalid plan type: {type(plan)}")

# _________________________PRIVATE METHODS_________________________ #

def _on_radiobutton_toggled(self, btn: QRadioButton, checked: bool) -> None:
btn2wdg: dict[QRadioButton, QWidget] = {
self.single_radio_btn: self.single_pos_wdg,
self.random_radio_btn: self.random_points_wdg,
self.grid_radio_btn: self.grid_wdg,
}
wdg = btn2wdg[btn]
wdg.setEnabled(checked)
if checked:
self._active_plan_widget = wdg
self.valueChanged.emit(self.value())

def _on_value_changed(self) -> None:
self.valueChanged.emit(self.value())

Check warning on line 162 in src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py

View check run for this annotation

Codecov / codecov/patch

src/pymmcore_widgets/useq_widgets/points_plans/_points_plan_selector.py#L162

Added line #L162 was not covered by tests
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
QComboBox,
QDoubleSpinBox,
QFormLayout,
QGroupBox,
QLabel,
QPushButton,
QSpinBox,
Expand All @@ -18,7 +17,7 @@
from useq import RandomPoints, Shape


class RandomPointWidget(QGroupBox):
class RandomPointWidget(QWidget):
"""Widget to generate random points within a specified area."""

valueChanged = Signal(object)
Expand Down Expand Up @@ -57,7 +56,7 @@ def __init__(self, parent: QWidget | None = None) -> None:
self.shape.setCurrentText(Shape.ELLIPSE.value)

# form layout
form = QFormLayout()
self.form = form = QFormLayout()
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.AllNonFixedFieldsGrow)
form.setSpacing(5)
form.setContentsMargins(0, 0, 0, 0)
Expand Down
88 changes: 54 additions & 34 deletions tests/useq_widgets/test_useq_points_plans.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,33 @@

from typing import TYPE_CHECKING

from useq import GridRowsColumns, OrderMode, RandomPoints
from useq import GridRowsColumns, OrderMode, RandomPoints, RelativePosition

from pymmcore_widgets.useq_widgets import points_plans as pp

if TYPE_CHECKING:
from pytestqt.qtbot import QtBot

RANDOM_POINTS = RandomPoints(
num_points=5,
shape="rectangle",
max_width=100,
max_height=100,
fov_height=10,
fov_width=10,
random_seed=123,
)

GRID_ROWS_COLS = GridRowsColumns(
rows=5,
columns=5,
overlap=(10, 12),
mode=OrderMode.column_wise_snake,
fov_height=10,
fov_width=12,
relative_to="top_left",
)


def test_random_points_widget(qtbot: QtBot) -> None:
wdg = pp.RandomPointWidget()
Expand All @@ -19,24 +39,15 @@ def test_random_points_widget(qtbot: QtBot) -> None:
assert wdg.shape.currentText() == "ellipse"
assert wdg.random_seed is not None

points = RandomPoints(
num_points=5,
shape="rectangle",
max_width=100,
max_height=100,
fov_height=10,
fov_width=10,
random_seed=123,
)
with qtbot.waitSignal(wdg.valueChanged):
wdg.setValue(points)
assert wdg.value() == points
wdg.setValue(RANDOM_POINTS)
assert wdg.value() == RANDOM_POINTS

assert wdg.num_points.value() == points.num_points
assert wdg.max_width.value() == points.max_width
assert wdg.max_height.value() == points.max_height
assert wdg.shape.currentText() == points.shape.value
assert wdg.random_seed == points.random_seed
assert wdg.num_points.value() == RANDOM_POINTS.num_points
assert wdg.max_width.value() == RANDOM_POINTS.max_width
assert wdg.max_height.value() == RANDOM_POINTS.max_height
assert wdg.shape.currentText() == RANDOM_POINTS.shape.value
assert wdg.random_seed == RANDOM_POINTS.random_seed


def test_grid_plan_widget(qtbot: QtBot) -> None:
Expand All @@ -48,21 +59,30 @@ def test_grid_plan_widget(qtbot: QtBot) -> None:
assert wdg.overlap_y.value() == 0
assert wdg.mode.currentText() == "row_wise_snake"

grid = GridRowsColumns(
rows=5,
columns=5,
overlap=(10, 12),
mode=OrderMode.column_wise_snake,
fov_height=10,
fov_width=12,
relative_to="top_left",
)
with qtbot.waitSignal(wdg.valueChanged):
wdg.setValue(grid)
assert wdg.value() == grid

assert wdg.rows.value() == grid.rows
assert wdg.columns.value() == grid.columns
assert wdg.overlap_x.value() == grid.overlap[0]
assert wdg.overlap_y.value() == grid.overlap[1]
assert wdg.mode.currentText() == grid.mode.value
wdg.setValue(GRID_ROWS_COLS)
assert wdg.value() == GRID_ROWS_COLS

assert wdg.rows.value() == GRID_ROWS_COLS.rows
assert wdg.columns.value() == GRID_ROWS_COLS.columns
assert wdg.overlap_x.value() == GRID_ROWS_COLS.overlap[0]
assert wdg.overlap_y.value() == GRID_ROWS_COLS.overlap[1]
assert wdg.mode.currentText() == GRID_ROWS_COLS.mode.value


def test_point_plan_selector(qtbot: QtBot) -> None:
wdg = pp.RelativePointPlanSelector()
qtbot.addWidget(wdg)

assert isinstance(wdg.value(), RelativePosition)

wdg.setValue(RANDOM_POINTS)
assert wdg.value() == RANDOM_POINTS
assert wdg.random_radio_btn.isChecked()

wdg.setValue(GRID_ROWS_COLS)
assert wdg.value() == GRID_ROWS_COLS
assert wdg.grid_radio_btn.isChecked()

wdg.random_radio_btn.setChecked(True)
assert wdg.value() == RANDOM_POINTS