Skip to content

Commit

Permalink
feat: add HCSWizard to MDAWIdget (#362)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people authored Oct 4, 2024
1 parent ecabdf3 commit 7b074d3
Show file tree
Hide file tree
Showing 5 changed files with 334 additions and 21 deletions.
5 changes: 5 additions & 0 deletions examples/mda_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@
It is fully connected to the CMMCorePlus object, and has a "run" button.
"""

from contextlib import suppress

import useq
from pymmcore_plus import CMMCorePlus
from qtpy.QtWidgets import QApplication

from pymmcore_widgets import MDAWidget

with suppress(ImportError):
from rich import print

app = QApplication([])

CMMCorePlus.instance().loadSystemConfiguration()
Expand Down
18 changes: 18 additions & 0 deletions src/pymmcore_widgets/mda/_core_mda.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,24 @@ def __init__(

self.destroyed.connect(self._disconnect)

# ----------- Override type hints in superclass -----------

@property
def channels(self) -> CoreConnectedChannelTable:
return cast("CoreConnectedChannelTable", self.tab_wdg.channels)

@property
def z_plan(self) -> CoreConnectedZPlanWidget:
return cast("CoreConnectedZPlanWidget", self.tab_wdg.z_plan)

@property
def stage_positions(self) -> CoreConnectedPositionTable:
return cast("CoreConnectedPositionTable", self.tab_wdg.stage_positions)

@property
def grid_plan(self) -> CoreConnectedGridPlanWidget:
return cast("CoreConnectedGridPlanWidget", self.tab_wdg.grid_plan)

# ------------------- public Methods ----------------------

def value(self) -> MDASequence:
Expand Down
183 changes: 176 additions & 7 deletions src/pymmcore_widgets/mda/_core_positions.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,37 @@
from __future__ import annotations

from typing import Any
from contextlib import suppress
from typing import TYPE_CHECKING, Any, Sequence

from fonticon_mdi6 import MDI6
from pymmcore_plus import CMMCorePlus
from pymmcore_plus._logger import logger
from pymmcore_plus._util import retry
from qtpy.QtWidgets import QCheckBox, QMessageBox, QWidget, QWidgetAction
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QCheckBox,
QMessageBox,
QPushButton,
QWidget,
QWidgetAction,
QWizard,
)
from superqt.utils import signals_blocked
from useq import WellPlatePlan

from pymmcore_widgets import HCSWizard
from pymmcore_widgets.useq_widgets import PositionTable
from pymmcore_widgets.useq_widgets._column_info import (
ButtonColumn,
)
from pymmcore_widgets.useq_widgets._positions import AF_DEFAULT_TOOLTIP

if TYPE_CHECKING:
from useq import Position

UPDATE_POSITIONS = "Update Positions List"
ADD_POSITIONS = "Add to Positions List"


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

# -------------- HCS Wizard ----------------
self._hcs_wizard: HCSWizard | None = None
self._plate_plan: WellPlatePlan | None = None

self._hcs_button = QPushButton("Well Plate...")
# self._hcs_button.setIcon(icon(MDI6.view_comfy))
self._hcs_button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self._hcs_button.setToolTip("Open the HCS wizard.")
self._hcs_button.clicked.connect(self._show_hcs)

self._edit_hcs_pos = QPushButton("Make Editable")
self._edit_hcs_pos.setToolTip(
"Convert HCS positions to regular editable positions."
)
self._edit_hcs_pos.setStyleSheet("color: red")
self._edit_hcs_pos.hide()
self._edit_hcs_pos.clicked.connect(self._show_pos_editing_dialog)

self._btn_row.insertWidget(3, self._hcs_button)
self._btn_row.insertWidget(3, self._edit_hcs_pos)
# ------------------------------------------

self.move_to_selection = QCheckBox("Move Stage to Selected Point")
# add a button to update XY to the current position
self._xy_btn_col = ButtonColumn(
Expand All @@ -53,19 +92,20 @@ def __init__(
self._z_btn_col = ButtonColumn(
key="z_btn", glyph=MDI6.arrow_left, on_click=self._set_z_from_core
)
self.table().addColumn(self._xy_btn_col, self.table().indexOf(self.X))
self.table().addColumn(self._z_btn_col, self.table().indexOf(self.Z) + 1)
self.table().addColumn(self._af_btn_col, self.table().indexOf(self.AF) + 1)
table = self.table()
table.addColumn(self._xy_btn_col, table.indexOf(self.X))
table.addColumn(self._z_btn_col, table.indexOf(self.Z) + 1)
table.addColumn(self._af_btn_col, table.indexOf(self.AF) + 1)

# when a new row is inserted, call _on_rows_inserted
# to update the new values from the core position
self.table().model().rowsInserted.connect(self._on_rows_inserted)
table.model().rowsInserted.connect(self._on_rows_inserted)

# add move_to_selection to toolbar and link up callback
toolbar = self.toolBar()
action0 = next(x for x in toolbar.children() if isinstance(x, QWidgetAction))
toolbar.insertWidget(action0, self.move_to_selection)
self.table().itemSelectionChanged.connect(self._on_selection_change)
table.itemSelectionChanged.connect(self._on_selection_change)

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

# ---------------------- public methods -----------------------

def value(
self, exclude_unchecked: bool = True, exclude_hidden_cols: bool = True
) -> tuple[Position, ...] | WellPlatePlan:
"""Return the current state of the positions table."""
if self._plate_plan is not None:
return self._plate_plan
return super().value(exclude_unchecked, exclude_hidden_cols)

def setValue(self, value: Sequence[Position] | WellPlatePlan) -> None:
"""Set the value of the positions table."""
if isinstance(value, WellPlatePlan):
self._plate_plan = value
self._hcs.setValue(value)
self._set_position_table_editable(False)
value = tuple(value)
super().setValue(value)

# ----------------------- private methods -----------------------

def _show_hcs(self) -> None:
"""Show or raise the HCS wizard."""
self._hcs.raise_() if self._hcs.isVisible() else self._hcs.show()

@property
def _hcs(self) -> HCSWizard:
"""Get the HCS wizard, initializing it if it doesn't exist."""
if self._hcs_wizard is None:
self._hcs_wizard = HCSWizard(self)
self._rename_hcs_position_button(ADD_POSITIONS)
self._hcs_wizard.accepted.connect(self._on_hcs_accepted)
return self._hcs_wizard

def _on_hcs_accepted(self) -> None:
"""Add the positions from the HCS wizard to the stage positions."""
self._plate_plan = self._hcs.value()
if self._plate_plan is not None:
# show a ovwerwrite warning dialog if the table is not empty
if self.table().rowCount() > 0:
dialog = QMessageBox(
QMessageBox.Icon.Warning,
"Overwrite Positions",
"This will replace the positions currently stored in the table."
"\nWould you like to proceed?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
self,
)
dialog.setDefaultButton(QMessageBox.StandardButton.Yes)
if dialog.exec() != QMessageBox.StandardButton.Yes:
return
self._update_table_positions(self._plate_plan)

def _update_table_positions(self, plan: WellPlatePlan) -> None:
"""Update the table with the positions from the HCS wizard."""
self.setValue(list(plan))
self._set_position_table_editable(False)

def _rename_hcs_position_button(self, text: str) -> None:
if wiz := self._hcs_wizard:
wiz.points_plan_page.setButtonText(QWizard.WizardButton.FinishButton, text)

def _show_pos_editing_dialog(self) -> None:
dialog = QMessageBox(
QMessageBox.Icon.Warning,
"Reset HCS",
"Positions are currently autogenerated from the HCS Wizard."
"\n\nWould you like to cast them to a list of stage positions?"
"\n\nNOTE: you will no longer be able to edit them using the HCS Wizard "
"widget.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
self,
)
dialog.setDefaultButton(QMessageBox.StandardButton.No)
if dialog.exec() == QMessageBox.StandardButton.Yes:
self._plate_plan = None
self._set_position_table_editable(True)

def _set_position_table_editable(self, state: bool) -> None:
"""Enable/disable the position table depending on the use of the HCS wizard."""
self._edit_hcs_pos.setVisible(not state)
self.include_z.setVisible(state)

# Hide or show all columns that are irrelevant when using the HCS wizard
table = self.table()
inc_z = self.include_z.isChecked()
table.setColumnHidden(table.indexOf(self._xy_btn_col), not state)
table.setColumnHidden(table.indexOf(self._z_btn_col), not state or not inc_z)
table.setColumnHidden(table.indexOf(self.Z), not state or not inc_z)
table.setColumnHidden(table.indexOf(self.SEQ), not state)

# Enable or disable the toolbar
for action in self.toolBar().actions()[1:]:
action.setEnabled(state)

self._enable_table_items(state)
# connect/disconnect the double click event and rename the button
if state:
self._rename_hcs_position_button(ADD_POSITIONS)
with suppress(RuntimeError):
self.table().cellDoubleClicked.disconnect(self._show_pos_editing_dialog)
else:
self._rename_hcs_position_button(UPDATE_POSITIONS)
# using UniqueConnection to avoid multiple connections
# but catching the TypeError if the connection is already made
with suppress(TypeError, RuntimeError):
self.table().cellDoubleClicked.connect(
self._show_pos_editing_dialog, Qt.ConnectionType.UniqueConnection
)

def _enable_table_items(self, state: bool) -> None:
"""Enable or disable the table items depending on the use of the HCS wizard."""
table = self.table()
name_col = table.indexOf(self.NAME)
x_col = table.indexOf(self.X)
y_col = table.indexOf(self.Y)
with signals_blocked(table):
for row in range(table.rowCount()):
table.cellWidget(row, x_col).setEnabled(state)
table.cellWidget(row, y_col).setEnabled(state)
# enable/disable the name cells
name_item = table.item(row, name_col)
flags = name_item.flags() | Qt.ItemFlag.ItemIsEnabled
if state:
flags |= Qt.ItemFlag.ItemIsEditable
else:
# keep the name column enabled but NOT editable. We do not disable
# to keep available the "Move Stage to Selected Point" option
flags &= ~Qt.ItemFlag.ItemIsEditable
name_item.setFlags(flags)

def _on_sys_config_loaded(self) -> None:
"""Update the table when the system configuration is loaded."""
self._update_xy_enablement()
Expand Down
20 changes: 10 additions & 10 deletions src/pymmcore_widgets/useq_widgets/_positions.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,22 +176,22 @@ def __init__(self, rows: int = 0, parent: QWidget | None = None):
self._load_button = QPushButton("Load...")
self._load_button.clicked.connect(self.load)

btn_row = QHBoxLayout()
btn_row.setSpacing(15)
btn_row.addWidget(self.include_z)
btn_row.addWidget(self.af_per_position)
btn_row.addStretch()
btn_row.addWidget(self._save_button)
btn_row.addWidget(self._load_button)
self._btn_row = QHBoxLayout()
self._btn_row.setSpacing(15)
self._btn_row.addWidget(self.include_z)
self._btn_row.addWidget(self.af_per_position)
self._btn_row.addStretch()
self._btn_row.addWidget(self._save_button)
self._btn_row.addWidget(self._load_button)

layout = cast("QVBoxLayout", self.layout())
layout.addLayout(btn_row)
layout.addLayout(self._btn_row)

# ------------------------- Public API -------------------------

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

return tuple(out)

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

0 comments on commit 7b074d3

Please sign in to comment.