Skip to content

Commit 7b074d3

Browse files
fdrgsptlambert03pre-commit-ci[bot]
authored
feat: add HCSWizard to MDAWIdget (#362)
* PlateDatabaseWidget * delete some stuff * add example * running * wip * lots of cleanup * style(pre-commit.ci): auto fixes [...] * more tweaks * feat: wip PlateTestWidget * fix: wip * feat: click wip * feat: working stage movements + example * feat: preset movements * fix: _xy_label * fix: SELECTED_MOVE_TO_COLOR * fix: _MoveToItem size and color * fix: refactor * fix: adjust position if rotation * fix: invert rotated_y sign * fix: rename + fix _PresetPositionItem size * fix: update example with rotation * fix: remove print * test: wip * fix: use qtpy * test: update * test: update * fix: remove print * test: update * test: update * test: fix * fix: move test into hcs folder * fix: rename * fix: clear * fix: setPlate * fix: update layout * fix: margins * fix: spacing * feat: add well name to _xy_label * fix: spacing * unifying features * feat: calibration tab + _test_well_btn * fix: use _current_calibration_widget * fix: rename + random test points * fix: wip * changes * fix: update setPlate method * fix: refactor * fix: remove unused * fix: _add_preset_positions_items * fix: _get_random_edge_point * test: update tests * test: update tests * style(pre-commit.ci): auto fixes [...] * feat: add new calibration wdg * fix: rename methods * add _calibrated flag * rename to value and setValue * fix _on_plate_changed] * todo * add label * change click signal * minor * fix: add QGroupBox * fix: sizes + rotation * fix: accept * fix: HoverEllipse tooltip * fix: accept * fix: example * fix: example * fix: deselect wells with no explicit selection * undo last change * more changes in behavior * some docs * remove WellPlateWidgetNoRotation * supress runtime error * updates * fix: clear selection when plate changes * fix: emit signal when selecting a different plate * fix: update fov size when calling setValue * fix: test_well_btn no focus policy * fix: fix bug when setting selected_wells * test: fix * feat: start adding HCSWizard * feat: add hcs edit positions btn * fix: z column * fix: move logic to core position table * test: start adding tests * test: more tests * feat: add position overwrite warning dialog * Update src/pymmcore_widgets/mda/_core_positions.py * undo change * Refactor HCS wizard integration in CoreConnectedPositionTable * fix test * more updates * test: add failing test * fix: fix signal emitted too many times * fix: keep AF checkbox visible * test: update * test: comment --------- Co-authored-by: Talley Lambert <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent ecabdf3 commit 7b074d3

File tree

5 files changed

+334
-21
lines changed

5 files changed

+334
-21
lines changed

examples/mda_widget.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@
33
It is fully connected to the CMMCorePlus object, and has a "run" button.
44
"""
55

6+
from contextlib import suppress
7+
68
import useq
79
from pymmcore_plus import CMMCorePlus
810
from qtpy.QtWidgets import QApplication
911

1012
from pymmcore_widgets import MDAWidget
1113

14+
with suppress(ImportError):
15+
from rich import print
16+
1217
app = QApplication([])
1318

1419
CMMCorePlus.instance().loadSystemConfiguration()

src/pymmcore_widgets/mda/_core_mda.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,24 @@ def __init__(
111111

112112
self.destroyed.connect(self._disconnect)
113113

114+
# ----------- Override type hints in superclass -----------
115+
116+
@property
117+
def channels(self) -> CoreConnectedChannelTable:
118+
return cast("CoreConnectedChannelTable", self.tab_wdg.channels)
119+
120+
@property
121+
def z_plan(self) -> CoreConnectedZPlanWidget:
122+
return cast("CoreConnectedZPlanWidget", self.tab_wdg.z_plan)
123+
124+
@property
125+
def stage_positions(self) -> CoreConnectedPositionTable:
126+
return cast("CoreConnectedPositionTable", self.tab_wdg.stage_positions)
127+
128+
@property
129+
def grid_plan(self) -> CoreConnectedGridPlanWidget:
130+
return cast("CoreConnectedGridPlanWidget", self.tab_wdg.grid_plan)
131+
114132
# ------------------- public Methods ----------------------
115133

116134
def value(self) -> MDASequence:

src/pymmcore_widgets/mda/_core_positions.py

Lines changed: 176 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,37 @@
11
from __future__ import annotations
22

3-
from typing import Any
3+
from contextlib import suppress
4+
from typing import TYPE_CHECKING, Any, Sequence
45

56
from fonticon_mdi6 import MDI6
67
from pymmcore_plus import CMMCorePlus
78
from pymmcore_plus._logger import logger
89
from pymmcore_plus._util import retry
9-
from qtpy.QtWidgets import QCheckBox, QMessageBox, QWidget, QWidgetAction
10+
from qtpy.QtCore import Qt
11+
from qtpy.QtWidgets import (
12+
QCheckBox,
13+
QMessageBox,
14+
QPushButton,
15+
QWidget,
16+
QWidgetAction,
17+
QWizard,
18+
)
1019
from superqt.utils import signals_blocked
20+
from useq import WellPlatePlan
1121

22+
from pymmcore_widgets import HCSWizard
1223
from pymmcore_widgets.useq_widgets import PositionTable
1324
from pymmcore_widgets.useq_widgets._column_info import (
1425
ButtonColumn,
1526
)
1627
from pymmcore_widgets.useq_widgets._positions import AF_DEFAULT_TOOLTIP
1728

29+
if TYPE_CHECKING:
30+
from useq import Position
31+
32+
UPDATE_POSITIONS = "Update Positions List"
33+
ADD_POSITIONS = "Add to Positions List"
34+
1835

1936
class CoreConnectedPositionTable(PositionTable):
2037
"""[PositionTable](../PositionTable#) connected to a Micro-Manager core instance.
@@ -45,6 +62,28 @@ def __init__(
4562
super().__init__(rows, parent)
4663
self._mmc = mmcore or CMMCorePlus.instance()
4764

65+
# -------------- HCS Wizard ----------------
66+
self._hcs_wizard: HCSWizard | None = None
67+
self._plate_plan: WellPlatePlan | None = None
68+
69+
self._hcs_button = QPushButton("Well Plate...")
70+
# self._hcs_button.setIcon(icon(MDI6.view_comfy))
71+
self._hcs_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
72+
self._hcs_button.setToolTip("Open the HCS wizard.")
73+
self._hcs_button.clicked.connect(self._show_hcs)
74+
75+
self._edit_hcs_pos = QPushButton("Make Editable")
76+
self._edit_hcs_pos.setToolTip(
77+
"Convert HCS positions to regular editable positions."
78+
)
79+
self._edit_hcs_pos.setStyleSheet("color: red")
80+
self._edit_hcs_pos.hide()
81+
self._edit_hcs_pos.clicked.connect(self._show_pos_editing_dialog)
82+
83+
self._btn_row.insertWidget(3, self._hcs_button)
84+
self._btn_row.insertWidget(3, self._edit_hcs_pos)
85+
# ------------------------------------------
86+
4887
self.move_to_selection = QCheckBox("Move Stage to Selected Point")
4988
# add a button to update XY to the current position
5089
self._xy_btn_col = ButtonColumn(
@@ -53,19 +92,20 @@ def __init__(
5392
self._z_btn_col = ButtonColumn(
5493
key="z_btn", glyph=MDI6.arrow_left, on_click=self._set_z_from_core
5594
)
56-
self.table().addColumn(self._xy_btn_col, self.table().indexOf(self.X))
57-
self.table().addColumn(self._z_btn_col, self.table().indexOf(self.Z) + 1)
58-
self.table().addColumn(self._af_btn_col, self.table().indexOf(self.AF) + 1)
95+
table = self.table()
96+
table.addColumn(self._xy_btn_col, table.indexOf(self.X))
97+
table.addColumn(self._z_btn_col, table.indexOf(self.Z) + 1)
98+
table.addColumn(self._af_btn_col, table.indexOf(self.AF) + 1)
5999

60100
# when a new row is inserted, call _on_rows_inserted
61101
# to update the new values from the core position
62-
self.table().model().rowsInserted.connect(self._on_rows_inserted)
102+
table.model().rowsInserted.connect(self._on_rows_inserted)
63103

64104
# add move_to_selection to toolbar and link up callback
65105
toolbar = self.toolBar()
66106
action0 = next(x for x in toolbar.children() if isinstance(x, QWidgetAction))
67107
toolbar.insertWidget(action0, self.move_to_selection)
68-
self.table().itemSelectionChanged.connect(self._on_selection_change)
108+
table.itemSelectionChanged.connect(self._on_selection_change)
69109

70110
# connect
71111
self._mmc.events.systemConfigurationLoaded.connect(self._on_sys_config_loaded)
@@ -77,8 +117,137 @@ def __init__(
77117
# hide the set-AF-offset button to begin with.
78118
self._on_af_per_position_toggled(self.af_per_position.isChecked())
79119

120+
# ---------------------- public methods -----------------------
121+
122+
def value(
123+
self, exclude_unchecked: bool = True, exclude_hidden_cols: bool = True
124+
) -> tuple[Position, ...] | WellPlatePlan:
125+
"""Return the current state of the positions table."""
126+
if self._plate_plan is not None:
127+
return self._plate_plan
128+
return super().value(exclude_unchecked, exclude_hidden_cols)
129+
130+
def setValue(self, value: Sequence[Position] | WellPlatePlan) -> None:
131+
"""Set the value of the positions table."""
132+
if isinstance(value, WellPlatePlan):
133+
self._plate_plan = value
134+
self._hcs.setValue(value)
135+
self._set_position_table_editable(False)
136+
value = tuple(value)
137+
super().setValue(value)
138+
80139
# ----------------------- private methods -----------------------
81140

141+
def _show_hcs(self) -> None:
142+
"""Show or raise the HCS wizard."""
143+
self._hcs.raise_() if self._hcs.isVisible() else self._hcs.show()
144+
145+
@property
146+
def _hcs(self) -> HCSWizard:
147+
"""Get the HCS wizard, initializing it if it doesn't exist."""
148+
if self._hcs_wizard is None:
149+
self._hcs_wizard = HCSWizard(self)
150+
self._rename_hcs_position_button(ADD_POSITIONS)
151+
self._hcs_wizard.accepted.connect(self._on_hcs_accepted)
152+
return self._hcs_wizard
153+
154+
def _on_hcs_accepted(self) -> None:
155+
"""Add the positions from the HCS wizard to the stage positions."""
156+
self._plate_plan = self._hcs.value()
157+
if self._plate_plan is not None:
158+
# show a ovwerwrite warning dialog if the table is not empty
159+
if self.table().rowCount() > 0:
160+
dialog = QMessageBox(
161+
QMessageBox.Icon.Warning,
162+
"Overwrite Positions",
163+
"This will replace the positions currently stored in the table."
164+
"\nWould you like to proceed?",
165+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
166+
self,
167+
)
168+
dialog.setDefaultButton(QMessageBox.StandardButton.Yes)
169+
if dialog.exec() != QMessageBox.StandardButton.Yes:
170+
return
171+
self._update_table_positions(self._plate_plan)
172+
173+
def _update_table_positions(self, plan: WellPlatePlan) -> None:
174+
"""Update the table with the positions from the HCS wizard."""
175+
self.setValue(list(plan))
176+
self._set_position_table_editable(False)
177+
178+
def _rename_hcs_position_button(self, text: str) -> None:
179+
if wiz := self._hcs_wizard:
180+
wiz.points_plan_page.setButtonText(QWizard.WizardButton.FinishButton, text)
181+
182+
def _show_pos_editing_dialog(self) -> None:
183+
dialog = QMessageBox(
184+
QMessageBox.Icon.Warning,
185+
"Reset HCS",
186+
"Positions are currently autogenerated from the HCS Wizard."
187+
"\n\nWould you like to cast them to a list of stage positions?"
188+
"\n\nNOTE: you will no longer be able to edit them using the HCS Wizard "
189+
"widget.",
190+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
191+
self,
192+
)
193+
dialog.setDefaultButton(QMessageBox.StandardButton.No)
194+
if dialog.exec() == QMessageBox.StandardButton.Yes:
195+
self._plate_plan = None
196+
self._set_position_table_editable(True)
197+
198+
def _set_position_table_editable(self, state: bool) -> None:
199+
"""Enable/disable the position table depending on the use of the HCS wizard."""
200+
self._edit_hcs_pos.setVisible(not state)
201+
self.include_z.setVisible(state)
202+
203+
# Hide or show all columns that are irrelevant when using the HCS wizard
204+
table = self.table()
205+
inc_z = self.include_z.isChecked()
206+
table.setColumnHidden(table.indexOf(self._xy_btn_col), not state)
207+
table.setColumnHidden(table.indexOf(self._z_btn_col), not state or not inc_z)
208+
table.setColumnHidden(table.indexOf(self.Z), not state or not inc_z)
209+
table.setColumnHidden(table.indexOf(self.SEQ), not state)
210+
211+
# Enable or disable the toolbar
212+
for action in self.toolBar().actions()[1:]:
213+
action.setEnabled(state)
214+
215+
self._enable_table_items(state)
216+
# connect/disconnect the double click event and rename the button
217+
if state:
218+
self._rename_hcs_position_button(ADD_POSITIONS)
219+
with suppress(RuntimeError):
220+
self.table().cellDoubleClicked.disconnect(self._show_pos_editing_dialog)
221+
else:
222+
self._rename_hcs_position_button(UPDATE_POSITIONS)
223+
# using UniqueConnection to avoid multiple connections
224+
# but catching the TypeError if the connection is already made
225+
with suppress(TypeError, RuntimeError):
226+
self.table().cellDoubleClicked.connect(
227+
self._show_pos_editing_dialog, Qt.ConnectionType.UniqueConnection
228+
)
229+
230+
def _enable_table_items(self, state: bool) -> None:
231+
"""Enable or disable the table items depending on the use of the HCS wizard."""
232+
table = self.table()
233+
name_col = table.indexOf(self.NAME)
234+
x_col = table.indexOf(self.X)
235+
y_col = table.indexOf(self.Y)
236+
with signals_blocked(table):
237+
for row in range(table.rowCount()):
238+
table.cellWidget(row, x_col).setEnabled(state)
239+
table.cellWidget(row, y_col).setEnabled(state)
240+
# enable/disable the name cells
241+
name_item = table.item(row, name_col)
242+
flags = name_item.flags() | Qt.ItemFlag.ItemIsEnabled
243+
if state:
244+
flags |= Qt.ItemFlag.ItemIsEditable
245+
else:
246+
# keep the name column enabled but NOT editable. We do not disable
247+
# to keep available the "Move Stage to Selected Point" option
248+
flags &= ~Qt.ItemFlag.ItemIsEditable
249+
name_item.setFlags(flags)
250+
82251
def _on_sys_config_loaded(self) -> None:
83252
"""Update the table when the system configuration is loaded."""
84253
self._update_xy_enablement()

src/pymmcore_widgets/useq_widgets/_positions.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -176,22 +176,22 @@ def __init__(self, rows: int = 0, parent: QWidget | None = None):
176176
self._load_button = QPushButton("Load...")
177177
self._load_button.clicked.connect(self.load)
178178

179-
btn_row = QHBoxLayout()
180-
btn_row.setSpacing(15)
181-
btn_row.addWidget(self.include_z)
182-
btn_row.addWidget(self.af_per_position)
183-
btn_row.addStretch()
184-
btn_row.addWidget(self._save_button)
185-
btn_row.addWidget(self._load_button)
179+
self._btn_row = QHBoxLayout()
180+
self._btn_row.setSpacing(15)
181+
self._btn_row.addWidget(self.include_z)
182+
self._btn_row.addWidget(self.af_per_position)
183+
self._btn_row.addStretch()
184+
self._btn_row.addWidget(self._save_button)
185+
self._btn_row.addWidget(self._load_button)
186186

187187
layout = cast("QVBoxLayout", self.layout())
188-
layout.addLayout(btn_row)
188+
layout.addLayout(self._btn_row)
189189

190190
# ------------------------- Public API -------------------------
191191

192192
def value(
193193
self, exclude_unchecked: bool = True, exclude_hidden_cols: bool = True
194-
) -> tuple[useq.Position, ...]:
194+
) -> Sequence[useq.Position]:
195195
"""Return the current value of the table as a tuple of [useq.Position](https://pymmcore-plus.github.io/useq-schema/schema/axes/#useq.Position).
196196
197197
Parameters
@@ -235,7 +235,7 @@ def value(
235235

236236
return tuple(out)
237237

238-
def setValue(self, value: Sequence[useq.Position]) -> None: # type: ignore
238+
def setValue(self, value: Sequence[useq.Position]) -> None: # type: ignore [override]
239239
"""Set the current value of the table from a Sequence of [useq.Position](https://pymmcore-plus.github.io/useq-schema/schema/axes/#useq.Position).
240240
241241
Parameters

0 commit comments

Comments
 (0)