diff --git a/src/napari_micromanager/_gui_objects/_toolbar.py b/src/napari_micromanager/_gui_objects/_toolbar.py index dd551a43..7f638e8e 100644 --- a/src/napari_micromanager/_gui_objects/_toolbar.py +++ b/src/napari_micromanager/_gui_objects/_toolbar.py @@ -9,7 +9,6 @@ CameraRoiWidget, ChannelGroupWidget, ChannelWidget, - ConfigurationWidget, DefaultCameraExposureWidget, GroupPresetTableWidget, LiveButton, @@ -18,6 +17,11 @@ SnapButton, ) +from napari_micromanager._util import ( + load_sys_config_dialog, + save_sys_config_dialog, +) + try: # this was renamed from pymmcore_widgets import ObjectivesPixelConfigurationWidget @@ -51,10 +55,31 @@ TOOL_SIZE = 35 +class GroupsAndPresets(GroupPresetTableWidget): + """Subclass of GroupPresetTableWidget. + + Overwrite the save and load methods to store the saved or loaded configuration in + the USER_CONFIGS_PATHS json config file. + """ + + def __init__( + self, *, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None + ) -> None: + super().__init__(parent=parent, mmcore=mmcore) + + def _save_cfg(self) -> None: + """Open file dialog to save the current configuration.""" + save_sys_config_dialog(parent=self, mmcore=self._mmc) + + def _load_cfg(self) -> None: + """Open file dialog to select a config file.""" + load_sys_config_dialog(parent=self, mmcore=self._mmc) + + # Dict for QObject and its QPushButton icon DOCK_WIDGETS: Dict[str, Tuple[type[QWidget], str | None]] = { # noqa: U006 "Device Property Browser": (PropertyBrowser, MDI6.table_large), - "Groups and Presets Table": (GroupPresetTableWidget, MDI6.table_large_plus), + "Groups and Presets Table": (GroupsAndPresets, MDI6.table_large_plus), "Illumination Control": (IlluminationWidget, MDI6.lightbulb_on), "Stages Control": (MMStagesWidget, MDI6.arrow_all), "Camera ROI": (CameraRoiWidget, MDI6.crop), @@ -103,14 +128,13 @@ def __init__(self, viewer: napari.viewer.Viewer) -> None: self._dock_widgets: dict[str, QDockWidget] = {} # add toolbar items toolbar_items = [ - ConfigToolBar(self), - ChannelsToolBar(self), ObjectivesToolBar(self), - None, - ShuttersToolBar(self), - SnapLiveToolBar(self), + ChannelsToolBar(self), ExposureToolBar(self), + SnapLiveToolBar(self), ToolsToolBar(self), + None, + ShuttersToolBar(self), ] for item in toolbar_items: if item: @@ -241,13 +265,6 @@ def addSubWidget(self, wdg: QWidget) -> None: cast("QHBoxLayout", self.frame.layout()).addWidget(wdg) -class ConfigToolBar(MMToolBar): - def __init__(self, parent: QWidget) -> None: - super().__init__("Configuration", parent) - self.addSubWidget(ConfigurationWidget()) - self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - - class ObjectivesToolBar(MMToolBar): def __init__(self, parent: QWidget) -> None: super().__init__("Objectives", parent=parent) diff --git a/src/napari_micromanager/_init_system_configs.py b/src/napari_micromanager/_init_system_configs.py new file mode 100644 index 00000000..5d793ffd --- /dev/null +++ b/src/napari_micromanager/_init_system_configs.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import cast +from warnings import warn + +from pymmcore_plus import CMMCorePlus, find_micromanager +from pymmcore_widgets import ConfigWizard +from pymmcore_widgets.hcwizard.finish_page import DEST_CONFIG +from qtpy.QtCore import QObject +from qtpy.QtWidgets import ( + QComboBox, + QDialog, + QDialogButtonBox, + QFileDialog, + QGridLayout, + QLabel, + QPushButton, + QSizePolicy, + QWidget, +) + +from napari_micromanager._util import ( + USER_CONFIGS_PATHS, + USER_DIR, + add_path_to_config_json, + load_sys_config, +) + +FIXED = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) +NEW = "New Hardware Configuration" + + +class InitializeSystemConfigurations(QObject): + def __init__( + self, + parent: QObject | None = None, + config: Path | str | None = None, + mmcore: CMMCorePlus | None = None, + ) -> None: + super().__init__(parent) + + self._mmc = mmcore or CMMCorePlus.instance() + + self._initialize() + + # if a config is provided, load it + if config is not None: + # add the config to the system configurations json and set it as the + # current configuration path. + add_path_to_config_json(config) + load_sys_config(config) + # if no config is provided, show a dialog to select one or to create a new one + else: + self._startup_dialog = StartupConfigurationsDialog( + parent=self.parent(), config=config, mmcore=self._mmc + ) + self._startup_dialog.show() + + def _initialize(self) -> None: + """Create or update the list of Micro-Manager hardware configurations paths. + + This method is called everytime napari-micromanager is loaded and it updates (or + create if does not yet exists) the list of Micro-Manager configurations paths + saved in the USER_CONFIGS_PATHS as a json file. + """ + # create USER_CONFIGS_PATHS if it doesn't exist + if not USER_CONFIGS_PATHS.exists(): + USER_DIR.mkdir(parents=True, exist_ok=True) + with open(USER_CONFIGS_PATHS, "w") as f: + json.dump({"paths": []}, f) + + # get the paths from the json file + configs_paths = self._get_config_paths() + + # write the data back to the file + with open(USER_CONFIGS_PATHS, "w") as f: + json.dump({"paths": configs_paths}, f) + + def _get_config_paths(self) -> list[str]: + """Return the paths from the json file. + + If a file stored in the json file doesn't exist, it is removed from the list. + + The method also adds all the .cfg files in the MicroManager folder to the list + if they are not already there. + """ + try: + with open(USER_CONFIGS_PATHS) as f: + data = json.load(f) + + # get path list from json file + paths = cast(list, data.get("paths", [])) + + # remove any path that doesn't exist + for path in paths: + if not Path(path).exists(): + paths.remove(path) + + # get all the .cfg files in the MicroManager folder + cfg_files = self._get_micromanager_cfg_files() + + # add all the .cfg files to the list if they are not already there + for cfg in reversed(cfg_files): + if str(cfg) not in paths: + # using insert so we leave the empty string at the end + paths.insert(0, str(cfg)) + + except json.JSONDecodeError: + paths = [] + warn("Error reading the json file.", stacklevel=2) + + return paths + + def _get_micromanager_cfg_files(self) -> list[Path]: + """Return all the .cfg files from all the MicroManager folders.""" + mm: list = find_micromanager(False) + cfg_files: list[Path] = [] + for mm_dir in mm: + cfg_files.extend(Path(mm_dir).glob("*.cfg")) + + return cfg_files + + +class StartupConfigurationsDialog(QDialog): + """A dialog to select the Micro-Manager Hardware configuration files at startup.""" + + def __init__( + self, + parent: QWidget | None = None, + *, + config: Path | str | None = None, + mmcore: CMMCorePlus | None = None, + ) -> None: + super().__init__(parent) + self.setWindowTitle("Micro-Manager Hardware System Configurations") + + self._mmc = mmcore or CMMCorePlus.instance() + self._config = config + + # label + cfg_lbl = QLabel("Configuration file:") + cfg_lbl.setSizePolicy(FIXED) + + # combo box + self.cfg_combo = QComboBox() + # `AdjustToMinimumContents` is not available in all qtpy backends so using + # `AdjustToMinimumContentsLengthWithIcon` instead + self.cfg_combo.setSizeAdjustPolicy( + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon + ) + + # browse button + self.browse_btn = QPushButton("...") + self.browse_btn.setSizePolicy(FIXED) + self.browse_btn.clicked.connect(self._on_browse_clicked) + + # Create OK and Cancel buttons + button_box = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + ) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + + # add widgets to layout + wdg_layout = QGridLayout(self) + wdg_layout.addWidget(cfg_lbl, 0, 0) + wdg_layout.addWidget(self.cfg_combo, 0, 1) + wdg_layout.addWidget(self.browse_btn, 0, 2) + wdg_layout.addWidget(button_box, 2, 0, 1, 3) + + self._initialize() + + def accept(self) -> None: + super().accept() + config = self.cfg_combo.currentText() + # if current text is not at index 0, update the json file and insert it at the + # first position so it will be shown as the fort option in the combo box next + # time the dialog is shown. + if config != self.cfg_combo.itemText(0): + add_path_to_config_json(config) + + # if the user selected NEW, show the config wizard + if config == NEW: + self._cfg_wizard = HardwareConfigWizard(parent=self) + self._cfg_wizard.show() + else: + load_sys_config(config) + + def _initialize(self) -> None: + """Initialize the dialog with the Micro-Manager configuration files.""" + # return if the json file doesn't exist + if not USER_CONFIGS_PATHS.exists(): + return + + # Read the existing data + try: + with open(USER_CONFIGS_PATHS) as f: + configs_paths = json.load(f) + except json.JSONDecodeError: + configs_paths = {"paths": []} + + configs_paths = cast(list, configs_paths.get("paths", [])) + # add the paths to the combo box + self.cfg_combo.addItems([*configs_paths, NEW]) + + # resize the widget so its width is not too small + self.resize(600, self.minimumSizeHint().height()) + + def _on_browse_clicked(self) -> None: + """Open a file dialog to select a file. + + If a file path is provided, it is added to the USER_CONFIGS_PATHS json file and + to the combo box. + """ + path, _ = QFileDialog.getOpenFileName( + self, "Open file", "", "MicroManager files (*.cfg)" + ) + if path: + # using insert so we leave the empty string at the end + self.cfg_combo.insertItem(0, path) + self.cfg_combo.setCurrentText(path) + # add the config to the system configurations json and set it as the + # current configuration path. + add_path_to_config_json(path) + + +class HardwareConfigWizard(ConfigWizard): + """A wizard to create a new Micro-Manager hardware configuration file. + + Subclassing to load the newly created configuration file and to add it to the + USER_CONFIGS_PATHS json file. + """ + + def __init__( + self, + config_file: str = "", + core: CMMCorePlus | None = None, + parent: QWidget | None = None, + ): + super().__init__(config_file, core, parent) + + self.setWindowTitle("Micro-Manager Hardware Configuration Wizard") + + def accept(self) -> None: + """Accept the wizard and save the configuration to a file. + + Overriding to add the new configuration file to the USER_CONFIGS_PATHS json file + and to load it. + """ + super().accept() + dest = self.field(DEST_CONFIG) + # add the path to the USER_CONFIGS_PATHS list + add_path_to_config_json(dest) + load_sys_config(dest) diff --git a/src/napari_micromanager/_util.py b/src/napari_micromanager/_util.py index 686459f2..60c426c8 100644 --- a/src/napari_micromanager/_util.py +++ b/src/napari_micromanager/_util.py @@ -1,12 +1,19 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from pathlib import Path +from typing import TYPE_CHECKING, cast +from warnings import warn -if TYPE_CHECKING: - from pathlib import Path +from platformdirs import user_config_dir +from pymmcore_plus import CMMCorePlus +from qtpy.QtWidgets import QFileDialog, QWidget +if TYPE_CHECKING: import useq +USER_DIR = Path(user_config_dir("napari_micromanager")) +USER_CONFIGS_PATHS = USER_DIR / "system_configurations.json" + # key in MDASequence.metadata to store napari-micromanager metadata # note that this is also used in napari layer metadata NMM_METADATA_KEY = "napari_micromanager" @@ -68,3 +75,76 @@ def ensure_unique(path: Path, extension: str = ".tif", ndigits: int = 3) -> Path # build new path name number = f"_{current_max+1:0{ndigits}d}" return path.parent / f"{stem}{number}{extension}" + + +def add_path_to_config_json(path: Path | str) -> None: + """Update the stystem configurations json file with the new path.""" + import json + + if not USER_CONFIGS_PATHS.exists(): + return + + if isinstance(path, Path): + path = str(path) + + # Read the existing data + try: + with open(USER_CONFIGS_PATHS) as f: + data = json.load(f) + except json.JSONDecodeError: + data = {"paths": []} + + # Append the new path. using insert so we leave the empty string at the end + paths = cast(list, data.get("paths", [])) + if path in paths: + paths.remove(path) + paths.insert(0, path) + + # Write the data back to the file + with open(USER_CONFIGS_PATHS, "w") as f: + json.dump({"paths": paths}, f) + + +def save_sys_config_dialog( + parent: QWidget | None = None, mmcore: CMMCorePlus | None = None +) -> None: + """Open file dialog to save a config file. + + The file will be also saved in the USER_CONFIGS_PATHS jason file if it doesn't + yet exist. + """ + (filename, _) = QFileDialog.getSaveFileName( + parent, "Save Micro-Manager Configuration." + ) + if filename: + filename = filename if str(filename).endswith(".cfg") else f"{filename}.cfg" + mmcore = mmcore or CMMCorePlus.instance() + mmcore.saveSystemConfiguration(filename) + add_path_to_config_json(filename) + + +def load_sys_config_dialog( + parent: QWidget | None = None, mmcore: CMMCorePlus | None = None +) -> None: + """Open file dialog to select a config file. + + The loaded file will be also saved in the USER_CONFIGS_PATHS jason file if it + doesn't yet exist. + """ + (filename, _) = QFileDialog.getOpenFileName( + parent, "Select a Micro-Manager configuration file", "", "cfg(*.cfg)" + ) + if filename: + add_path_to_config_json(filename) + mmcore = mmcore or CMMCorePlus.instance() + mmcore.loadSystemConfiguration(filename) + + +def load_sys_config(config: Path | str, mmcore: CMMCorePlus | None = None) -> None: + """Load a system configuration with a warning if the file is not found.""" + mmcore = mmcore or CMMCorePlus.instance() + try: + mmcore.loadSystemConfiguration(config) + except FileNotFoundError: + # don't crash if the user passed an invalid config + warn(f"Config file {config} not found. Nothing loaded.", stacklevel=2) diff --git a/src/napari_micromanager/main_window.py b/src/napari_micromanager/main_window.py index 4d3ba523..d77fc8cd 100644 --- a/src/napari_micromanager/main_window.py +++ b/src/napari_micromanager/main_window.py @@ -3,16 +3,24 @@ import atexit import contextlib import logging -from typing import TYPE_CHECKING, Any, Callable -from warnings import warn +from typing import TYPE_CHECKING, Any, Callable, cast import napari import napari.layers import napari.viewer from pymmcore_plus import CMMCorePlus +from pymmcore_widgets.hcwizard.intro_page import SRC_CONFIG +from qtpy.QtWidgets import QAction, QMenuBar + +from napari_micromanager._util import ( + load_sys_config, + load_sys_config_dialog, + save_sys_config_dialog, +) from ._core_link import CoreViewerLink from ._gui_objects._toolbar import MicroManagerToolbar +from ._init_system_configs import HardwareConfigWizard, InitializeSystemConfigurations if TYPE_CHECKING: from pathlib import Path @@ -29,10 +37,16 @@ class MainWindow(MicroManagerToolbar): """The main napari-micromanager widget that gets added to napari.""" def __init__( - self, viewer: napari.viewer.Viewer, config: str | Path | None = None + self, + viewer: napari.viewer.Viewer, + config: str | Path | None = None, + *, + init_configs: bool = True, ) -> None: super().__init__(viewer) + self._add_menu() + # get global CMMCorePlus instance self._mmc = CMMCorePlus.instance() # this object mediates the connection between the viewer and core events @@ -55,12 +69,24 @@ def __init__( self.destroyed.connect(self._cleanup) atexit.register(self._cleanup) - if config is not None: - try: - self._mmc.loadSystemConfiguration(config) - except FileNotFoundError: - # don't crash if the user passed an invalid config - warn(f"Config file {config} not found. Nothing loaded.", stacklevel=2) + # Micro-Manager Hardware Configuration Wizard + self._wiz: HardwareConfigWizard | None = None + + if init_configs: + # handle the system configurations at startup. with this we create/update + # the list of the Micro-Manager hardware system configurations files path + # stored as a json file in the user's configuration file directory + # (USER_CONFIGS_PATHS). + # a dialog will be also displayed if no system configuration file is + # provided to either select one from the list of available ones or to create + # a new one. + self._init_cfg = InitializeSystemConfigurations( + parent=self.viewer.window._qt_window, config=config, mmcore=self._mmc + ) + return + + if config: + load_sys_config(config) def _cleanup(self) -> None: for signal, slot in self._connections: @@ -75,3 +101,47 @@ def _update_max_min(self, *_: Any) -> None: self.minmax.update_from_layers( lr for lr in visible if isinstance(lr, napari.layers.Image) ) + + def _add_menu(self) -> None: + if (win := getattr(self.viewer.window, "_qt_window", None)) is None: + return + + menubar = cast(QMenuBar, win.menuBar()) + + # main Micro-Manager menu + mm_menu = menubar.addMenu("Micro-Manager") + + # Configurations Sub-Menu + configurations_menu = mm_menu.addMenu("System Configurations") + # save cfg + self.act_save_configuration = QAction("Save Configuration", self) + self.act_save_configuration.triggered.connect(self._save_cfg) + configurations_menu.addAction(self.act_save_configuration) + # load cfg + self.act_load_configuration = QAction("Load Configuration", self) + self.act_load_configuration.triggered.connect(self._load_cfg) + configurations_menu.addAction(self.act_load_configuration) + # cfg wizard + self.act_cfg_wizard = QAction("Hardware Configuration Wizard", self) + self.act_cfg_wizard.triggered.connect(self._show_config_wizard) + configurations_menu.addAction(self.act_cfg_wizard) + + def _save_cfg(self) -> None: + """Save the current Micro-Manager system configuration.""" + save_sys_config_dialog(parent=self.viewer.window._qt_window, mmcore=self._mmc) + + def _load_cfg(self) -> None: + """Load a Micro-Manager system configuration.""" + load_sys_config_dialog(parent=self.viewer.window._qt_window, mmcore=self._mmc) + + def _show_config_wizard(self) -> None: + """Show the Micro-Manager Hardware Configuration Wizard.""" + if self._wiz is None: + self._wiz = HardwareConfigWizard(parent=self.viewer.window._qt_window) + + if self._wiz.isVisible(): + self._wiz.raise_() + else: + current_cfg = self._mmc.systemConfigurationFile() or "" + self._wiz.setField(SRC_CONFIG, current_cfg) + self._wiz.show() diff --git a/tests/conftest.py b/tests/conftest.py index cde3cad5..6010a2e2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,7 +21,7 @@ def core(monkeypatch): @pytest.fixture def main_window(core: CMMCorePlus, make_napari_viewer): viewer = make_napari_viewer() - win = MainWindow(viewer=viewer) + win = MainWindow(viewer=viewer, init_configs=False) assert core == win._mmc return win diff --git a/tests/test_configurations.py b/tests/test_configurations.py new file mode 100644 index 00000000..8eb2e347 --- /dev/null +++ b/tests/test_configurations.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +from napari_micromanager._init_system_configs import ( + InitializeSystemConfigurations, +) +from napari_micromanager._util import USER_CONFIGS_PATHS + +if TYPE_CHECKING: + from pymmcore_plus import CMMCorePlus + from pytestqt.qtbot import QtBot + +DEMO = "MMConfig_demo.cfg" +NEW = "New Hardware Configuration" + +configs = [None, Path(__file__).parent / "test_config.cfg"] + + +@pytest.mark.parametrize("config", configs) +@pytest.mark.parametrize("wiz", [True, False]) +def test_config_init(qtbot: QtBot, core: CMMCorePlus, config: Path | None, wiz: bool): + assert not USER_CONFIGS_PATHS.exists() + + init = InitializeSystemConfigurations(mmcore=core, config=config) + + with open(USER_CONFIGS_PATHS) as f: + data = json.load(f) + # the default config should be in the json file + assert DEMO in [Path(path).name for path in data["paths"]] + + if config is None: + assert init._startup_dialog.isVisible() + combo = init._startup_dialog.cfg_combo + assert DEMO and NEW in [ + Path(combo.itemText(i)).name for i in range(combo.count()) + ] + + # set the combo to new so that after accepting the config wizard should + # be visible + combo.setCurrentText(NEW if wiz else DEMO) + current_cfg = combo.currentText() + + # simulate click on ok + init._startup_dialog.accept() + + # only if DEMO cfg was selected, the config wizard should be visible + if wiz: + assert init._startup_dialog._cfg_wizard.isVisible() + + else: + assert not hasattr(init, "_startup_dialog") + current_cfg = config + assert str(current_cfg) in data["paths"] + + # a config should have been loaded only if DEMO cfg was selected + if not wiz: + assert core.systemConfigurationFile() == str(current_cfg) + + USER_CONFIGS_PATHS.unlink() + + +# TODO: test the config wizard and the menu actions diff --git a/tests/test_main.py b/tests/test_main.py index 5cae6d6b..ba614d10 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -3,6 +3,7 @@ import pytest from napari_micromanager.__main__ import main +from napari_micromanager._util import USER_CONFIGS_PATHS from pymmcore_plus import CMMCorePlus @@ -35,3 +36,6 @@ def test_cli_main(argv: list) -> None: # this is to prevent a leaked widget error in the NEXT test napari.current_viewer().close() QtViewer._instances.clear() + + if USER_CONFIGS_PATHS.exists(): + USER_CONFIGS_PATHS.unlink() diff --git a/tests/test_main_window.py b/tests/test_main_window.py index ef4d3a1f..3ed01b0c 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -17,7 +17,7 @@ def test_main_window(qtbot: QtBot, core: CMMCorePlus) -> None: This test should remain fast. """ viewer = MagicMock() - wdg = MainWindow(viewer) + wdg = MainWindow(viewer, init_configs=False) qtbot.addWidget(wdg) viewer.layers.events.connect.assert_called_once_with(wdg._update_max_min)