From b8a05f37d1196583449f06cf333269ffccbf2fac Mon Sep 17 00:00:00 2001
From: Talley Lambert <talley.lambert@gmail.com>
Date: Sun, 3 Sep 2023 14:09:20 -0400
Subject: [PATCH] refactor: changing toolbar structure (#281)

* changing toolbar structure

* Update src/napari_micromanager/_gui_objects/_toolbar.py

Co-authored-by: federico gasparoli <70725613+fdrgsp@users.noreply.github.com>

* Update src/napari_micromanager/_gui_objects/_toolbar.py

Co-authored-by: federico gasparoli <70725613+fdrgsp@users.noreply.github.com>

* bump version

* layout tweaks

* fix width issue

---------

Co-authored-by: federico gasparoli <70725613+fdrgsp@users.noreply.github.com>
---
 pyproject.toml                                |   2 +-
 .../_gui_objects/_mda_widget.py               |   1 -
 .../_gui_objects/_toolbar.py                  | 366 +++++++-----------
 src/napari_micromanager/main_window.py        |   3 +-
 4 files changed, 140 insertions(+), 232 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 1034c436..19b8991c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,7 +37,7 @@ dependencies = [
     "fonticon-materialdesignicons6",
     "napari >=0.4.13",
     "pymmcore-plus >=0.8.0",
-    "pymmcore-widgets >=0.4.1",
+    "pymmcore-widgets >=0.4.2",
     "superqt >=0.5.1",
     "tifffile",
     "useq-schema >=0.4.1",
diff --git a/src/napari_micromanager/_gui_objects/_mda_widget.py b/src/napari_micromanager/_gui_objects/_mda_widget.py
index 7fbc41e6..1c535fea 100644
--- a/src/napari_micromanager/_gui_objects/_mda_widget.py
+++ b/src/napari_micromanager/_gui_objects/_mda_widget.py
@@ -31,7 +31,6 @@ def __init__(
         self, *, parent: QWidget | None = None, mmcore: CMMCorePlus | None = None
     ) -> None:
         super().__init__(include_run_button=True, parent=parent, mmcore=mmcore)
-
         # add save widget
         v_layout = cast(QVBoxLayout, self._central_widget.layout())
         self._save_groupbox = SaveWidget()
diff --git a/src/napari_micromanager/_gui_objects/_toolbar.py b/src/napari_micromanager/_gui_objects/_toolbar.py
index ed0b9237..bf53d454 100644
--- a/src/napari_micromanager/_gui_objects/_toolbar.py
+++ b/src/napari_micromanager/_gui_objects/_toolbar.py
@@ -21,7 +21,7 @@
 from qtpy.QtCore import QEvent, QObject, QSize, Qt
 from qtpy.QtWidgets import (
     QDockWidget,
-    QGroupBox,
+    QFrame,
     QHBoxLayout,
     QLabel,
     QMainWindow,
@@ -42,9 +42,7 @@
 if TYPE_CHECKING:
     import napari.viewer
 
-TOOLBAR_SIZE = 45
 TOOL_SIZE = 35
-GROUPBOX_STYLE = "QGroupBox { border-radius: 3px; }"
 
 
 # Dict for QObject and its QPushButton icon
@@ -87,15 +85,14 @@ def __init__(self, viewer: napari.viewer.Viewer) -> None:
         self._dock_widgets: dict[str, QDockWidget] = {}
         # add toolbar items
         toolbar_items = [
-            self._add_cfg(),
-            self._add_objective(),
-            self._add_channels(),
-            self._add_exposure(),
-            self._add_snap_live_toolbar(),
-            self._add_tools_toolsbar(),
-            self._add_plugins_toolbar(),
-            "",
-            self._add_shutter_toolbar(),
+            ConfigToolBar(self),
+            ChannelsToolBar(self),
+            ObjectivesToolBar(self),
+            None,
+            ShuttersToolBar(self),
+            SnapLiveToolBar(self),
+            ExposureToolBar(self),
+            ToolsToolBar(self),
         ]
         for item in toolbar_items:
             if item:
@@ -111,7 +108,6 @@ def _initialize(self) -> None:
             win := getattr(self.viewer.window, "_qt_window", None)
         ):
             return
-
         win = cast(QMainWindow, win)
         if (
             isinstance(dw := self.parent(), QDockWidget)
@@ -125,7 +121,7 @@ def _initialize(self) -> None:
             dw.setVisible(was_visible)  # necessary after using removeDockWidget
             self.removeEventFilter(self)
 
-    def eventFilter(self, obj: QObject, event: QEvent) -> bool:
+    def eventFilter(self, obj: QObject | None, event: QEvent | None) -> bool:
         """Event filter that ensures that this widget is shown at the top.
 
         npe2 plugins don't have a way to specify where they should be added, so this
@@ -135,7 +131,8 @@ def eventFilter(self, obj: QObject, event: QEvent) -> bool:
         # the move event is one of the first events that is fired when the widget is
         # docked, so we use it to re-dock this widget at the top
         if (
-            event.type() == QEvent.Type.Move
+            event
+            and event.type() == QEvent.Type.Move
             and obj is self
             and not self._is_initialized
         ):
@@ -143,219 +140,6 @@ def eventFilter(self, obj: QObject, event: QEvent) -> bool:
 
         return False
 
-    def _add_cfg(self) -> QToolBar:
-        """Create a QToolBar with the `ConfigurationWidget`."""
-        cfg_toolbar = QToolBar("Configuration", self)
-        cfg_toolbar.setObjectName("MM-Configuration")
-        cfg_toolbar.setMinimumHeight(TOOLBAR_SIZE)
-        cfg_toolbar.setSizePolicy(
-            QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
-        )
-        cfg_wdg = ConfigurationWidget()
-        cfg_wdg.setStyleSheet(GROUPBOX_STYLE)
-        cfg_wdg.setTitle("")
-        cfg_wdg.layout().setContentsMargins(5, 0, 5, 0)
-        cfg_toolbar.addWidget(cfg_wdg)
-
-        return cfg_toolbar
-
-    def _create_groupbox(self) -> QGroupBox:
-        wdg = QGroupBox()
-        wdg.setLayout(QHBoxLayout())
-        wdg.layout().setContentsMargins(5, 0, 5, 0)
-        wdg.layout().setSpacing(0)
-        return wdg
-
-    def _add_objective(self) -> QToolBar:
-        """Create a QToolBar with the `ObjectivesWidget`."""
-        obj_toolbar = QToolBar("Objectives", self)
-        obj_toolbar.setObjectName("MM-Objectives")
-        obj_toolbar.setMinimumHeight(TOOLBAR_SIZE)
-
-        wdg = self._create_groupbox()
-        wdg.setStyleSheet(GROUPBOX_STYLE)
-        # TODO: add this directly to ObjectivesWidget
-        self.obj_wdg = ObjectivesWidget()
-        self.obj_wdg.setMinimumWidth(0)
-        self.obj_wdg._mmc.events.systemConfigurationLoaded.connect(self._resize_obj)
-        self.obj_wdg._combo.setSizePolicy(
-            QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed
-        )
-        wdg.layout().addWidget(self.obj_wdg)
-        obj_toolbar.addWidget(wdg)
-
-        return obj_toolbar
-
-    # TODO: add this directly to ObjectivesWidget
-    def _resize_obj(self) -> None:
-        self.obj_wdg._combo.setSizePolicy(
-            QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed
-        )
-        self.obj_wdg.setMinimumWidth(0)
-        self.obj_wdg._combo.adjustSize()
-
-    def _add_snap_live_toolbar(self) -> QToolBar:
-        """Create a QToolBar with the `SnapButton` and `LiveButton`."""
-        snap_live_toolbar = QToolBar("Snap Live", self)
-        snap_live_toolbar.setObjectName("MM-Snap")
-        snap_live_toolbar.setMinimumHeight(TOOLBAR_SIZE)
-
-        wdg = self._create_groupbox()
-        wdg.layout().setSpacing(3)
-        wdg.setStyleSheet("border: 0px;")
-
-        snap_btn = SnapButton()
-        snap_btn.setText("")
-        snap_btn.setToolTip("Snap")
-        snap_btn.setFixedSize(TOOL_SIZE, TOOL_SIZE)
-        wdg.layout().addWidget(snap_btn)
-
-        live_btn = LiveButton()
-        live_btn.setText("")
-        live_btn.setToolTip("Live Mode")
-        live_btn.button_text_off = ""
-        live_btn.button_text_on = ""
-        live_btn.setFixedSize(TOOL_SIZE, TOOL_SIZE)
-        wdg.layout().addWidget(live_btn)
-
-        snap_live_toolbar.addWidget(wdg)
-
-        return snap_live_toolbar
-
-    def _add_channels(self) -> QToolBar:
-        """Create a QToolBar with the `ChannelWidget`."""
-        ch_toolbar = QToolBar("Channels", self)
-        ch_toolbar.setObjectName("MM-Channels")
-        ch_toolbar.setMinimumHeight(TOOLBAR_SIZE)
-
-        wdg = self._create_groupbox()
-        wdg.layout().setSpacing(5)
-        wdg.setStyleSheet(GROUPBOX_STYLE)
-
-        ch_lbl = QLabel(text="Channel:")
-        wdg.layout().addWidget(ch_lbl)
-        wdg.layout().addWidget(ChannelGroupWidget())
-        wdg.layout().addWidget(ChannelWidget())
-
-        ch_toolbar.addWidget(wdg)
-
-        return ch_toolbar
-
-    def _add_exposure(self) -> QToolBar:
-        """Create a QToolBar with the `DefaultCameraExposureWidget`."""
-        exp_toolbar = QToolBar("Exposure", self)
-        exp_toolbar.setObjectName("MM-Exposure")
-        exp_toolbar.setMinimumHeight(TOOLBAR_SIZE)
-
-        wdg = self._create_groupbox()
-        wdg.setStyleSheet(GROUPBOX_STYLE)
-
-        exposure_wdg = DefaultCameraExposureWidget()
-        exp_lbl = QLabel(text="Exposure:")
-        exp_layout = cast(QHBoxLayout, exposure_wdg.layout())
-        exp_layout.setContentsMargins(0, 0, 0, 0)
-        exp_layout.setSpacing(3)
-        exp_layout.insertWidget(0, exp_lbl)
-        wdg.layout().addWidget(exposure_wdg)
-
-        exp_toolbar.addWidget(wdg)
-
-        return exp_toolbar
-
-    def _add_shutter_toolbar(self) -> QToolBar:
-        """Create a QToolBar with the `MMShuttersWidget`."""
-        shutters_toolbar = QToolBar("Shutters", self)
-        shutters_toolbar.setObjectName("MM-Shutters")
-        shutters_toolbar.setMinimumHeight(TOOLBAR_SIZE)
-
-        wdg = self._create_groupbox()
-        wdg.layout().setSpacing(3)
-        wdg.setStyleSheet("border: 0px;")
-
-        shutter_wdg = MMShuttersWidget()
-        shutter_wdg.setMinimumHeight(TOOL_SIZE)
-        wdg.layout().addWidget(shutter_wdg)
-
-        shutters_toolbar.addWidget(wdg)
-
-        return shutters_toolbar
-
-    def _add_tools_toolsbar(self) -> QToolBar:
-        """Add a QToolBar containing QPushButtons for pymmcore-widgets.
-
-        e.g. Property Browser, GroupPresetTableWidget, ...
-
-        QPushButtons are connected to the `_show_dock_widget` method.
-
-        The QPushButton.whatsThis() property is used to store the key that
-        will be used by the `_show_dock_widget` method.
-        """
-        tools_toolbar = QToolBar("Tools", self)
-        tools_toolbar.setObjectName("MM-Tools")
-        tools_toolbar.setMinimumHeight(TOOLBAR_SIZE)
-
-        wdg = self._create_groupbox()
-        wdg.layout().setSpacing(3)
-        wdg.setStyleSheet("border: 0px;")
-
-        for key in DOCK_WIDGETS:
-            btn_icon = DOCK_WIDGETS[key][1]
-            if btn_icon is None:
-                continue
-            btn = self._make_tool_button(key, btn_icon)
-            btn.setWhatsThis(key)
-            btn.clicked.connect(self._show_dock_widget)
-            wdg.layout().addWidget(btn)
-
-        tools_toolbar.addWidget(wdg)
-
-        return tools_toolbar
-
-    def _make_tool_button(self, tooltip: str, btn_icon: str) -> QPushButton:
-        """Create the QPushbutton for the tools QToolBar."""
-        btn = QPushButton()
-        btn.setToolTip(tooltip)
-        btn.setFixedSize(TOOL_SIZE, TOOL_SIZE)
-        btn.setIcon(icon(btn_icon, color=(0, 255, 0)))
-        btn.setIconSize(QSize(30, 30))
-        return btn
-
-    def _add_plugins_toolbar(self) -> QToolBar:
-        """Add a QToolBar containing plugins QPushButtons.
-
-        e.g. MDA, ...
-
-        QPushButtons are connected to the `_show_dock_widget` method.
-
-        The QPushButton.whatsThis() property is used to store the key that
-        will be used by the `_show_dock_widget` method.
-        """
-        plgs_toolbar = QToolBar("Plugins")
-        plgs_toolbar.setObjectName("MM-Plugins")
-        plgs_toolbar.setMinimumHeight(TOOLBAR_SIZE)
-
-        wdg = QGroupBox()
-        wdg.setLayout(QHBoxLayout())
-        wdg.layout().setContentsMargins(5, 0, 5, 0)
-        wdg.layout().setSpacing(3)
-        wdg.setStyleSheet("border: 0px;")
-
-        mda = self._make_plugin_button("MDA", "MultiDimensional Acquisition")
-        wdg.layout().addWidget(mda)
-
-        plgs_toolbar.addWidget(wdg)
-
-        return plgs_toolbar
-
-    def _make_plugin_button(self, btn_text: str, tooltip: str) -> QPushButton:
-        """Create the QPushButton for the plugins QToolBar."""
-        btn = QPushButton(text=btn_text)
-        btn.setToolTip(tooltip)
-        btn.setMinimumHeight(TOOL_SIZE)
-        btn.setWhatsThis(btn_text)
-        btn.clicked.connect(self._show_dock_widget)
-        return btn
-
     def _show_dock_widget(self, key: str = "") -> None:
         """Look up widget class in DOCK_WIDGETS and add/create or show/raise.
 
@@ -396,7 +180,6 @@ def _show_dock_widget(self, key: str = "") -> None:
                 )
                 floating = True
                 tabify = False
-
             dock_wdg = self._add_dock_widget(wdg, key, floating=floating, tabify=tabify)
             self._dock_widgets[key] = dock_wdg
 
@@ -410,7 +193,132 @@ def _add_dock_widget(
             area="right",
             tabify=tabify,
         )
+        # fix napari bug that makes dock widgets too large
+        with contextlib.suppress(AttributeError):
+            self.viewer.window._qt_window.resizeDocks(
+                [dock_wdg], [widget.sizeHint().width() + 20], Qt.Orientation.Horizontal
+            )
         with contextlib.suppress(AttributeError):
             dock_wdg._close_btn = False
         dock_wdg.setFloating(floating)
         return dock_wdg
+
+
+# -------------- Toolbars --------------------
+
+
+class MMToolBar(QToolBar):
+    def __init__(self, title: str, parent: QWidget = None) -> None:
+        super().__init__(title, parent)
+        self.setMinimumHeight(48)
+        self.setObjectName(f"MM-{title}")
+
+        self.frame = QFrame()
+        gb_layout = QHBoxLayout(self.frame)
+        gb_layout.setContentsMargins(0, 0, 0, 0)
+        gb_layout.setSpacing(2)
+        self.addWidget(self.frame)
+
+    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)
+        self._wdg = ObjectivesWidget()
+        self._wdg._mmc.events.systemConfigurationLoaded.connect(self._resize_obj)
+        self.addSubWidget(self._wdg)
+        self._resize_obj()
+
+    def _resize_obj(self) -> None:
+        self._wdg._combo.setSizePolicy(
+            QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed
+        )
+        self._wdg.setMinimumWidth(0)
+        self._wdg._combo.adjustSize()
+
+
+class ChannelsToolBar(MMToolBar):
+    def __init__(self, parent: QWidget) -> None:
+        super().__init__("Channels", parent)
+        self.addSubWidget(QLabel(text="Channel:"))
+        self.addSubWidget(ChannelGroupWidget())
+        self.addSubWidget(ChannelWidget())
+
+
+class ExposureToolBar(MMToolBar):
+    def __init__(self, parent: QWidget) -> None:
+        super().__init__("Exposure", parent)
+        self.addSubWidget(QLabel(text="Exposure:"))
+        self.addSubWidget(DefaultCameraExposureWidget())
+
+
+class SnapLiveToolBar(MMToolBar):
+    def __init__(self, parent: QWidget) -> None:
+        super().__init__("Snap Live", parent)
+        snap_btn = SnapButton()
+        snap_btn.setText("")
+        snap_btn.setToolTip("Snap")
+        snap_btn.setFixedSize(TOOL_SIZE, TOOL_SIZE)
+        self.addSubWidget(snap_btn)
+
+        live_btn = LiveButton()
+        live_btn.setText("")
+        live_btn.setToolTip("Live Mode")
+        live_btn.button_text_off = ""
+        live_btn.button_text_on = ""
+        live_btn.setFixedSize(TOOL_SIZE, TOOL_SIZE)
+        self.addSubWidget(live_btn)
+
+
+class ToolsToolBar(MMToolBar):
+    """A QToolBar containing QPushButtons for pymmcore-widgets.
+
+    e.g. Property Browser, GroupPresetTableWidget, ...
+
+    QPushButtons are connected to the `_show_dock_widget` method.
+
+    The QPushButton.whatsThis() property is used to store the key that
+    will be used by the `_show_dock_widget` method.
+    """
+
+    def __init__(self, parent: MicroManagerToolbar) -> None:
+        super().__init__("Tools", parent)
+
+        if not isinstance(parent, MicroManagerToolbar):
+            raise TypeError("parent must be a MicroManagerToolbar instance.")
+
+        for key in DOCK_WIDGETS:
+            btn_icon = DOCK_WIDGETS[key][1]
+            if btn_icon is None:
+                continue
+
+            btn = QPushButton()
+            btn.setToolTip(key)
+            btn.setFixedSize(TOOL_SIZE, TOOL_SIZE)
+            btn.setIcon(icon(btn_icon, color=(0, 255, 0)))
+            btn.setIconSize(QSize(30, 30))
+            btn.setWhatsThis(key)
+            btn.clicked.connect(parent._show_dock_widget)
+            self.addSubWidget(btn)
+
+        btn = QPushButton("MDA")
+        btn.setToolTip("MultiDimensional Acquisition")
+        btn.setFixedSize(TOOL_SIZE, TOOL_SIZE)
+        btn.setWhatsThis("MDA")
+        btn.clicked.connect(parent._show_dock_widget)
+        self.addSubWidget(btn)
+
+
+class ShuttersToolBar(MMToolBar):
+    def __init__(self, parent: QWidget) -> None:
+        super().__init__("Shutters", parent)
+        self.addSubWidget(MMShuttersWidget())
diff --git a/src/napari_micromanager/main_window.py b/src/napari_micromanager/main_window.py
index 39665c30..02f40aba 100644
--- a/src/napari_micromanager/main_window.py
+++ b/src/napari_micromanager/main_window.py
@@ -56,7 +56,8 @@ def __init__(self, viewer: napari.viewer.Viewer) -> None:
             signal.connect(slot)
 
         # add minmax dockwidget
-        self.viewer.window.add_dock_widget(self.minmax, name="MinMax", area="left")
+        if "MinMax" not in getattr(self.viewer.window, "_dock_widgets", []):
+            self.viewer.window.add_dock_widget(self.minmax, name="MinMax", area="left")
 
         # queue cleanup
         self.destroyed.connect(self._cleanup)