diff --git a/src/napari_micromanager/_gui_objects/_toolbar.py b/src/napari_micromanager/_gui_objects/_toolbar.py index dd551a43..a60325e7 100644 --- a/src/napari_micromanager/_gui_objects/_toolbar.py +++ b/src/napari_micromanager/_gui_objects/_toolbar.py @@ -1,6 +1,9 @@ from __future__ import annotations +import base64 import contextlib +import json +from pathlib import Path from typing import TYPE_CHECKING, Dict, Tuple, cast from fonticon_mdi6 import MDI6 @@ -24,7 +27,8 @@ except ImportError: from pymmcore_widgets import PixelSizeWidget as ObjectivesPixelConfigurationWidget -from qtpy.QtCore import QEvent, QObject, QSize, Qt +from platformdirs import user_data_dir +from qtpy.QtCore import QByteArray, QEvent, QObject, QSize, Qt from qtpy.QtWidgets import ( QDockWidget, QFrame, @@ -32,6 +36,7 @@ QLabel, QMainWindow, QPushButton, + QScrollArea, QSizePolicy, QTabWidget, QToolBar, @@ -50,6 +55,10 @@ TOOL_SIZE = 35 +# Path to the user data directory to store the layout +USER_DATA_DIR = Path(user_data_dir(appname="napari_micromanager")) +USER_LAYOUT_PATH = USER_DATA_DIR / "napari_micromanager_layout.json" + # Dict for QObject and its QPushButton icon DOCK_WIDGETS: Dict[str, Tuple[type[QWidget], str | None]] = { # noqa: U006 @@ -198,7 +207,10 @@ def _show_dock_widget(self, key: str = "") -> None: ) floating = True tabify = False + + wdg = ScrollableWidget(self, title=key, widget=wdg) dock_wdg = self._add_dock_widget(wdg, key, floating=floating, tabify=tabify) + self._connect_dock_widget(dock_wdg) self._dock_widgets[key] = dock_wdg def _add_dock_widget( @@ -221,6 +233,109 @@ def _add_dock_widget( dock_wdg.setFloating(floating) return dock_wdg + def _connect_dock_widget(self, dock_wdg: QDockWidget) -> None: + """Connect the dock widget to the main window.""" + dock_wdg.visibilityChanged.connect(self._save_layout) + dock_wdg.topLevelChanged.connect(self._save_layout) + dock_wdg.dockLocationChanged.connect(self._save_layout) + + def _on_dock_widget_changed(self) -> None: + """Start a saving threrad to save the layout if the thread is not running.""" + + def _save_layout(self) -> None: + """Save the napa-micromanager layout to a json file. + + The json file has two keys: + - "layout_state" where the state of napari main window is stored using the + saveState() method. The state is base64 encoded to be able to save it to the + json file. + - "pymmcore_widgets" where the names of the docked pymmcore_widgets are stored. + + IMPORTANT: The "pymmcore_widgets" key is crucial in our layout saving process. + It stores the names of all active pymmcore_widgets at the time of saving. Before + restoring the layout, we must recreate these widgets. If not, they won't be + included in the restored layout. + """ + if getattr(self.viewer.window, "_qt_window", None) is None: + return + # get the names of the pymmcore_widgets that are part of the layout + pymmcore_wdgs: list[str] = [] + main_win = self.viewer.window._qt_window + for dock_wdg in main_win.findChildren(QDockWidget): + wdg_name = dock_wdg.objectName() + if wdg_name in DOCK_WIDGETS: + pymmcore_wdgs.append(wdg_name) + + # get the state of the napari main window as bytes + state_bytes = main_win.saveState().data() + + # Create dictionary with widget names and layout state. The layout state is + # base64 encoded to be able to save it to a json file. + data = { + "pymmcore_widgets": pymmcore_wdgs, + "layout_state": base64.b64encode(state_bytes).decode(), + } + + # if the user layout path does not exist, create it + if not USER_LAYOUT_PATH.exists(): + USER_DATA_DIR.mkdir(parents=True, exist_ok=True) + + try: + with open(USER_LAYOUT_PATH, "w") as json_file: + json.dump(data, json_file) + except Exception as e: + print(f"Was not able to save layout to file. Error: {e}") + + def _load_layout(self) -> None: + """Load the napari-micromanager layout from a json file.""" + if not USER_LAYOUT_PATH.exists(): + return + + try: + with open(USER_LAYOUT_PATH) as f: + data = json.load(f) + + # get the layout state bytes + state_bytes = data.get("layout_state") + + if state_bytes is None: + return + + # add pymmcore_widgets to the main window + pymmcore_wdgs = data.get("pymmcore_widgets", []) + for wdg_name in pymmcore_wdgs: + if wdg_name in DOCK_WIDGETS: + self._show_dock_widget(wdg_name) + + # Convert base64 encoded string back to bytes + state_bytes = base64.b64decode(state_bytes) + + # restore the layout state + self.viewer.window._qt_window.restoreState(QByteArray(state_bytes)) + + except Exception as e: + print(f"Was not able to load layout from file. Error: {e}") + + +class ScrollableWidget(QWidget): + """A QWidget with a QScrollArea. + + We use it to add a croll alre to the pymmcore_widgets. + """ + + def __init__(self, parent: QWidget | None = None, *, title: str, widget: QWidget): + super().__init__(parent) + self.setWindowTitle(title) + # create the scroll area and add the widget to it + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + layout = QHBoxLayout(self) + layout.addWidget(self.scroll_area) + # set the widget to the scroll area + self.scroll_area.setWidget(widget) + # resize the dock widget to the size hint of the widget + self.resize(widget.minimumSizeHint()) + # -------------- Toolbars -------------------- diff --git a/src/napari_micromanager/main_window.py b/src/napari_micromanager/main_window.py index 4d3ba523..bc00a575 100644 --- a/src/napari_micromanager/main_window.py +++ b/src/napari_micromanager/main_window.py @@ -19,7 +19,6 @@ from pymmcore_plus.core.events._protocol import PSignalInstance - # this is very verbose logging.getLogger("napari.loader").setLevel(logging.WARNING) logging.getLogger("in_n_out").setLevel(logging.WARNING) @@ -55,6 +54,10 @@ def __init__( self.destroyed.connect(self._cleanup) atexit.register(self._cleanup) + # load layout + self._load_layout() + + # load config file if config is not None: try: self._mmc.loadSystemConfiguration(config) diff --git a/tests/test_dockwidgets.py b/tests/test_dockwidgets.py index b9e5c8dc..71d1f231 100644 --- a/tests/test_dockwidgets.py +++ b/tests/test_dockwidgets.py @@ -2,14 +2,20 @@ from typing import TYPE_CHECKING -from napari_micromanager._gui_objects._toolbar import DOCK_WIDGETS +from napari_micromanager._gui_objects._toolbar import DOCK_WIDGETS, USER_LAYOUT_PATH if TYPE_CHECKING: from napari_micromanager.main_window import MainWindow def test_dockwidgets(main_window: MainWindow): + assert not USER_LAYOUT_PATH.exists() + for dw_name in DOCK_WIDGETS: assert dw_name not in main_window._dock_widgets main_window._show_dock_widget(dw_name) main_window._dock_widgets[dw_name].close() + + # a layout file should have been saved + assert USER_LAYOUT_PATH.exists() + USER_LAYOUT_PATH.unlink() diff --git a/tests/test_main.py b/tests/test_main.py index 5cae6d6b..799dc62b 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._gui_objects._toolbar import USER_LAYOUT_PATH from pymmcore_plus import CMMCorePlus @@ -18,6 +19,10 @@ def test_cli_main(argv: list) -> None: import napari from napari.qt import QtViewer + # remover any saved layout file + if USER_LAYOUT_PATH.exists(): + USER_LAYOUT_PATH.unlink() + with patch("napari.run") as mock_run: with patch("qtpy.QtWidgets.QMainWindow.show") as mock_show: if "nonexistent" in argv: diff --git a/tests/test_multid_widget.py b/tests/test_multid_widget.py index d43fbbe1..8df2889b 100644 --- a/tests/test_multid_widget.py +++ b/tests/test_multid_widget.py @@ -41,7 +41,9 @@ def test_saving_mda( ) -> None: mda = mda_sequence_splits main_window._show_dock_widget("MDA") - mda_widget = main_window._dock_widgets["MDA"].widget() + scroll_wdg = main_window._dock_widgets["MDA"].widget() + scroll_area = scroll_wdg.children()[-1] + mda_widget = scroll_area.widget() assert isinstance(mda_widget, MultiDWidget) dest = tmp_path / "thing.ome.tif"