Repository: brainglobe/brainglobe-utils (language: python)

Issue:
Unify headers for all napari plugins

Unify workflow napari container with ordered steps 
It would be nice to have a re-useable container widget for workflows that have steps in a napari plugin. We would expect these steps to be executed in order from top to bottom of a napari plugin.

See maybe https://stackoverflow.com/questions/11077793/is-there-a-standard-component-for-collapsible-panel-in-qt for inspiration for a possible implementation


Discussion / hints:

Alternative to consider, suggested by @lauraporta, would be next/back buttons.

Fix patch:
diff --git a/brainglobe_utils/qtpy/__init__.py b/brainglobe_utils/qtpy/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/brainglobe_utils/qtpy/brainglobe.png b/brainglobe_utils/qtpy/brainglobe.png
new file mode 100644
index 0000000..427bdab
Binary files /dev/null and b/brainglobe_utils/qtpy/brainglobe.png differ
diff --git a/brainglobe_utils/qtpy/collapsible_widget.py b/brainglobe_utils/qtpy/collapsible_widget.py
new file mode 100644
index 0000000..feeab84
--- /dev/null
+++ b/brainglobe_utils/qtpy/collapsible_widget.py
@@ -0,0 +1,136 @@
+from typing import List, Optional, Union
+
+from qtpy.QtCore import Qt, Signal
+from qtpy.QtWidgets import QGroupBox, QVBoxLayout, QWidget
+from superqt.collapsible import QCollapsible
+
+
+class CollapsibleWidget(QCollapsible):
+    """
+    Custom collapsible widget.
+
+    Attributes
+    ----------
+    toggled_signal_with_self : Signal
+        Emitted when the CollapsibleWidget is toggled.
+        Provides the CollapsibleWidget instance and the new state.
+
+    Parameters
+    ----------
+    title : str, optional
+        The title of the CollapsibleWidget.
+    parent : QWidget or None, optional
+        The parent widget.
+    """
+
+    toggled_signal_with_self = Signal(QCollapsible, bool)
+
+    def __init__(
+        self,
+        title: str = "",
+        parent: Optional[QWidget] = None,
+    ):
+        """
+        Initializes a new CollapsibleWidget instance.
+
+        Parameters
+        ----------
+        title : str, optional
+            The title of the CollapsibleWidget.
+        parent : QWidget or None, optional
+            The parent widget.
+        """
+        super().__init__(title, parent)
+        self.currently_expanded = False
+
+        self.toggled.connect(self._on_toggle)
+
+    def _on_toggle(self, state):
+        """
+        Handles the toggled signal by emitting the custom signal
+         with the CollapsibleWidget instance and the new state.
+
+        Parameters
+        ----------
+        state : bool
+            The new state of the CollapsibleWidget
+            (True if expanded, False if collapsed).
+        """
+        self.toggled_signal_with_self.emit(self, state)
+
+
+class CollapsibleWidgetContainer(QGroupBox):
+    """
+    Container for multiple CollapsibleWidgets with the ability to add,
+    remove, and synchronize their states.
+
+    Methods
+    -------
+    add_widget(QWidget or CollapsibleWidget)
+        Adds a widget to the CollapsibleWidgetContainer.
+    remove_drawer(QWidget or CollapsibleWidget)
+        Removes a widget from the CollapsibleWidgetContainer.
+    _update_drawers(signalling_drawer, state)
+        Private method to synchronize drawer states.
+    """
+
+    def __init__(self):
+        """
+        Initializes a new CollapsibleWidgetContainer instance.
+        """
+        super().__init__()
+        self.setLayout(QVBoxLayout())
+
+        self.layout().setAlignment(Qt.AlignTop)
+        self.layout().setSpacing(0)
+        self.layout().setContentsMargins(0, 0, 0, 0)
+        self.collapsible_widgets: List[CollapsibleWidget] = []
+
+    def add_widget(self, widget: Union[QWidget, CollapsibleWidget]):
+        """
+        Adds a QWidget or a CollapsibleWidget to the chest.
+
+        Parameters
+        ----------
+        widget : QWidget or CollapsibleWidget
+            The widget instance to be added.
+        """
+        if isinstance(widget, CollapsibleWidget):
+            self.collapsible_widgets.append(widget)
+            widget.toggled_signal_with_self.connect(self._update_drawers)
+
+        self.layout().addWidget(widget, 0, Qt.AlignTop)
+
+    def remove_widget(self, widget: Union[QWidget, CollapsibleWidget]):
+        """
+        Removes a widget from the chest.
+
+        Parameters
+        ----------
+        widget : QWidget or CollapsibleWidget
+            The widget instance to be removed.
+        """
+        if isinstance(widget, CollapsibleWidget):
+            self.collapsible_widgets.remove(widget)
+            widget.toggled_signal_with_self.disconnect(self._update_drawers)
+
+        self.layout().removeWidget(widget)
+
+    def _update_drawers(
+        self, signalling_widget: CollapsibleWidget, state: bool
+    ):
+        """
+        Synchronizes CollapsibleWidget states to ensure only one
+        CollapsibleWidget is expanded at a time.
+
+        Parameters
+        ----------
+        signalling_widget : CollapsibleWidget
+            The CollapsibleWidget emitting the signal.
+        state : bool
+            The new state of the signalling_widget.
+        """
+        if state:
+            for collapsible_widget in self.collapsible_widgets:
+                if collapsible_widget is not signalling_widget:
+                    collapsible_widget.collapse(False)
diff --git a/brainglobe_utils/qtpy/logo.py b/brainglobe_utils/qtpy/logo.py
new file mode 100644
index 0000000..003b669
--- /dev/null
+++ b/brainglobe_utils/qtpy/logo.py
@@ -0,0 +1,76 @@
+from importlib.resources import files
+
+from qtpy.QtWidgets import QGroupBox, QHBoxLayout, QLabel, QWidget
+
+
+def _docs_links_widget(
+    package_name: str,
+    package_tagline: str,
+    tutorial_file_name: str,
+    parent: QWidget = None,
+):
+    _docs_links_html = f"""
+    <h3>
+    <p>{package_tagline}</p>
+    <p><a href="https://brainglobe.info" style="color:gray;">Website</a></p>
+    <p><a href="https://brainglobe.info/tutorials/{tutorial_file_name}" style="color:gray;">Tutorial</a></p>
+    <p><a href="https://github.com/brainglobe/{package_name}" style="color:gray;">Source</a></p>
+    </h3>
+    """  # noqa: E501
+    docs_links_widget = QLabel(_docs_links_html, parent=parent)
+    docs_links_widget.setOpenExternalLinks(True)
+    return docs_links_widget
+
+
+def _logo_widget(package_name: str, parent: QWidget = None):
+    brainglobe_logo = files("brainglobe_utils").joinpath("qtpy/brainglobe.png")
+
+    _logo_html = f"""
+    <h1>
+    <img src="{brainglobe_logo}"width="100">
+    <p>{package_name}</p>
+    <\h1>
+    """  # noqa W605
+
+    return QLabel(_logo_html, parent=None)
+
+
+def header_widget(
+    package_name: str,
+    package_tagline: str,
+    tutorial_file_name: str,
+    parent: QWidget = None,
+):
+    """
+    Render HTML in a QGroupBox with a BrainGlobe logo and links to the docs
+     and source code.
+
+    Parameters
+    ----------
+    package_name : str
+        The name of the package, e.g. "brainrender-napari"
+    package_tagline : str
+        The tagline for the package
+    tutorial_file_name : str
+        The name of the tutorial file (must include .html),
+         e.g. "brainrender-qtpy.html": str
+    parent : QWidget
+        The parent widget, defaults to None
+
+    Returns
+    -------
+    QGroupBox
+        A QGroupBox with the rendered HTML containing the logo and links
+    """
+
+    box = QGroupBox(parent)
+    box.setFlat(True)
+    box.setLayout(QHBoxLayout())
+    box.layout().addWidget(_logo_widget(package_name, parent=box))
+    box.layout().addWidget(
+        _docs_links_widget(
+            package_name, package_tagline, tutorial_file_name, parent=box
+        )
+    )
+
+    return box
diff --git a/pyproject.toml b/pyproject.toml
index ee1bf25..cba5e47 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -14,7 +14,6 @@ dependencies = [
     "configobj",
     "tqdm",
     "PyYAML",
-    "pytest-qt",
     "scipy",
     "scikit-image",
 ]
@@ -41,11 +40,14 @@ user_support = "https://github.com/brainglobe/brainglobe-utils/issues"
 napari = [
   "napari>=0.4.18",
   "qtpy",
+  "superqt"
 ]
 
 dev = [
+  "qtpy",
   "pytest",
   "pytest-cov",
+  "pytest-qt",
   "coverage",
   "tox",
   "black",
@@ -53,9 +55,8 @@ dev = [
   "pre-commit",
   "ruff",
   "setuptools_scm",
-  "pytest-qt",
-  "qtpy",
-  "PyQt5",
+  "pyqt5",
+  "superqt",
 ]
 
 slumrmio = [
diff --git a/tox.ini b/tox.ini
index b26baec..3857869 100644
--- a/tox.ini
+++ b/tox.ini
@@ -13,3 +13,9 @@ extras =
     dev
 commands =
     pytest -v --color=yes --cov=brainglobe_utils --cov-report=xml
+passenv =
+    CI
+    GITHUB_ACTIONS
+    DISPLAY
+    XAUTHORITY
+    PYVISTA_OFF_SCREEN


Test patch:
diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml
index 5729807..286c6a8 100644
--- a/.github/workflows/test_and_deploy.yml
+++ b/.github/workflows/test_and_deploy.yml
@@ -31,6 +31,15 @@ jobs:
           python-version: "3.11"
 
     steps:
+      - name: Set up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+
+      # these libraries enable testing on Qt on linux
+      - uses: pyvista/setup-headless-display-action@v2
+        with:
+          qt: true
       # Run tests
       - uses: neuroinformatics-unit/actions/test@v2
         with:
diff --git a/tests/tests/test_unit/test_qtpy/__init__.py b/tests/tests/test_unit/test_qtpy/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/tests/test_unit/test_qtpy/test_collapsible_widget.py b/tests/tests/test_unit/test_qtpy/test_collapsible_widget.py
new file mode 100644
index 0000000..d9214f6
--- /dev/null
+++ b/tests/tests/test_unit/test_qtpy/test_collapsible_widget.py
@@ -0,0 +1,215 @@
+import pytest
+from qtpy.QtWidgets import QLabel, QPushButton
+
+from brainglobe_utils.qtpy.collapsible_widget import (
+    CollapsibleWidget,
+    CollapsibleWidgetContainer,
+)
+
+WIDGET_TITLE = "Title"
+
+
+@pytest.fixture(scope="class")
+def collapsible_widget() -> CollapsibleWidget:
+    collapsible_widget = CollapsibleWidget(WIDGET_TITLE)
+    return collapsible_widget
+
+
+@pytest.fixture(scope="class")
+def collapsible_widget_container() -> CollapsibleWidgetContainer:
+    collapsible_widget_container = CollapsibleWidgetContainer()
+    return collapsible_widget_container
+
+
+def test_collapsible_widget_empty(qtbot, collapsible_widget):
+    qtbot.addWidget(collapsible_widget)
+
+    assert collapsible_widget.text() == WIDGET_TITLE
+    assert not collapsible_widget.isExpanded()
+    assert collapsible_widget.content().layout().count() == 0
+
+
+def test_collapsible_widget_filled(qtbot, collapsible_widget):
+    label_str = "test"
+    collapsible_widget.addWidget(QPushButton(label_str))
+
+    qtbot.addWidget(collapsible_widget)
+
+    assert collapsible_widget.content().layout().count() == 1
+
+
+def test_collapsible_widget_click_once(qtbot, collapsible_widget):
+    qtbot.addWidget(collapsible_widget)
+
+    with qtbot.waitSignal(
+        collapsible_widget.toggled_signal_with_self, timeout=1000
+    ) as blocker:
+        collapsible_widget._toggle_btn.click()
+
+    assert blocker.args == [collapsible_widget, True]
+
+
+@pytest.mark.parametrize("num_clicks", [2, 5, 100])
+def test_collapsible_widget_click_multiple(
+    qtbot, collapsible_widget, num_clicks
+):
+    """
+    Test that the toggled_signal_with_self signal is emitted the correct
+    number of times and with the correct parameters when clicked multiple
+    times.
+    """
+    qtbot.addWidget(collapsible_widget)
+
+    current_state = collapsible_widget.isExpanded()
+
+    # Function to check the signal is emitted with the correct parameters
+    # The signaller should be the collapsible widget and the state should
+    # match the state tracked manually in current_state
+    def check_signal_valid(signaller, state):
+        return signaller == collapsible_widget and state == current_state
+
+    with qtbot.waitSignals(
+        [collapsible_widget.toggled_signal_with_self] * num_clicks,
+        check_params_cbs=[check_signal_valid] * num_clicks,
+        timeout=1000,
+    ):
+        for _ in range(num_clicks + 1):
+            # Invert the current state to simulate a toggle manually
+            current_state = not current_state
+            collapsible_widget._toggle_btn.click()
+
+
+def test_collapsible_widget_container(qtbot, collapsible_widget_container):
+    qtbot.addWidget(collapsible_widget_container)
+
+    assert collapsible_widget_container.layout().count() == 0
+    assert len(collapsible_widget_container.collapsible_widgets) == 0
+
+
+def test_collapsible_widget_container_add_collapsible_widget(
+    qtbot, collapsible_widget_container, collapsible_widget
+):
+    qtbot.addWidget(collapsible_widget_container)
+
+    collapsible_widget_container.add_widget(collapsible_widget)
+
+    assert collapsible_widget_container.layout().count() == 1
+    assert (
+        collapsible_widget_container.collapsible_widgets[0]
+        == collapsible_widget
+    )
+    assert len(collapsible_widget_container.collapsible_widgets) == 1
+
+
+def test_collapsible_widget_container_add_other_widget(
+    qtbot, collapsible_widget_container
+):
+    qtbot.addWidget(collapsible_widget_container)
+
+    collapsible_widget_container.add_widget(QLabel("test"))
+
+    assert collapsible_widget_container.layout().count() == 1
+    assert len(collapsible_widget_container.collapsible_widgets) == 0
+
+
+def test_collapsible_widget_container_add_remove_widgets(
+    qtbot, collapsible_widget, collapsible_widget_container
+):
+    qtbot.addWidget(collapsible_widget_container)
+
+    collapsible_widget_container.add_widget(collapsible_widget)
+
+    assert collapsible_widget_container.layout().count() == 1
+    assert len(collapsible_widget_container.collapsible_widgets) == 1
+
+    collapsible_widget_container.remove_widget(collapsible_widget)
+
+    assert collapsible_widget_container.layout().count() == 0
+    assert len(collapsible_widget_container.collapsible_widgets) == 0
+
+
+def test_collapsible_widget_container_add_remove_diff_widgets(
+    qtbot, collapsible_widget, collapsible_widget_container
+):
+    qtbot.addWidget(collapsible_widget_container)
+    other_widget = QLabel("test")
+
+    collapsible_widget_container.add_widget(collapsible_widget)
+    collapsible_widget_container.add_widget(other_widget)
+
+    assert collapsible_widget_container.layout().count() == 2
+    assert len(collapsible_widget_container.collapsible_widgets) == 1
+
+    collapsible_widget_container.remove_widget(collapsible_widget)
+
+    assert collapsible_widget_container.layout().count() == 1
+    assert len(collapsible_widget_container.collapsible_widgets) == 0
+
+    collapsible_widget_container.remove_widget(other_widget)
+
+    assert collapsible_widget_container.layout().count() == 0
+    assert len(collapsible_widget_container.collapsible_widgets) == 0
+
+
+@pytest.mark.parametrize(
+    "num_collapsible_widgets, num_other_widgets, index_expanded",
+    [(2, 4, 1), (5, 1, 3), (10, 0, 9)],
+)
+def test_collapsible_widget_container_update_drawers(
+    qtbot,
+    collapsible_widget_container,
+    num_collapsible_widgets,
+    num_other_widgets,
+    index_expanded,
+):
+    """
+    Test that the collapsible widgets in the container are expanded/collapsed
+    correctly when the _update_drawers function is called.
+
+    A container is created with num_collapsible_widgets collapsible widgets
+    and num_other_widgets other widgets. The index_expanded collapsible widget
+    is expanded and the state of each widget is checked. index_expanded is
+    then incremented (modulo num_collapsible_widgets) to simulate toggling
+    each collapsible widget in sequence. Checks the state of each widget after
+    every iteration.
+    """
+    qtbot.addWidget(collapsible_widget_container)
+    collapsible_widgets = []
+    non_collapsible_widgets = []
+
+    # Add collapsible widgets and other widgets to the container alternating
+    # until the correct number of each type of widget has been added
+    for i in range(num_collapsible_widgets + num_other_widgets):
+        if i % 2 == 0:
+            if len(collapsible_widgets) == num_collapsible_widgets:
+                non_collapsible_widgets.append(QLabel("test"))
+                collapsible_widget_container.add_widget(
+                    non_collapsible_widgets[-1]
+                )
+            else:
+                collapsible_widgets.append(CollapsibleWidget(WIDGET_TITLE))
+                collapsible_widget_container.add_widget(
+                    collapsible_widgets[-1]
+                )
+        else:
+            if len(non_collapsible_widgets) == num_other_widgets:
+                collapsible_widgets.append(CollapsibleWidget(WIDGET_TITLE))
+                collapsible_widget_container.add_widget(
+                    collapsible_widgets[-1]
+                )
+            else:
+                non_collapsible_widgets.append(QLabel("test"))
+                collapsible_widget_container.add_widget(
+                    non_collapsible_widgets[-1]
+                )
+
+    for _ in range(num_collapsible_widgets):
+        collapsible_widgets[index_expanded]._toggle_btn.click()
+
+        for i in range(num_collapsible_widgets):
+            assert collapsible_widgets[i].isExpanded() == (i == index_expanded)
+
+        for widget in non_collapsible_widgets:
+            assert not widget.isHidden()
+
+        index_expanded = (index_expanded + 1) % num_collapsible_widgets
diff --git a/tests/tests/test_unit/test_qtpy/test_logo.py b/tests/tests/test_unit/test_qtpy/test_logo.py
new file mode 100644
index 0000000..e1134be
--- /dev/null
+++ b/tests/tests/test_unit/test_qtpy/test_logo.py
@@ -0,0 +1,27 @@
+from brainglobe_utils.qtpy.logo import header_widget
+
+
+def test_logo(qtbot):
+    package_name = "test"
+    package_tagline = "Tagline"
+    package_tutorial = "test.html"
+
+    header = header_widget(package_name, package_tagline, package_tutorial)
+
+    qtbot.addWidget(header)
+
+    expected_strings_logo = [package_name, "brainglobe.png"]
+    expected_strings_docs = [
+        package_tagline,
+        package_tutorial,
+        "https://brainglobe.info",
+        "https://github.com",
+    ]
+
+    assert header.layout().count() == 2
+
+    for logo_string in expected_strings_logo:
+        assert logo_string in header.layout().itemAt(0).widget().text()
+
+    for docs_string in expected_strings_docs:
+        assert docs_string in header.layout().itemAt(1).widget().text()