Repository: AllenCell/napari-allencell-segmenter (language: python)

Issue:
Feature/collapsible box custom widget
Resolves #45 

* Created a custom widget class `CollapsibleBox` inside a new folder called `widgets`
* Added CollapsibleBox widgets in 3 different initial states (enabled and closed, enabled and open, and disabled) to the bottom of the workflow selection window as examples
* The example CollapsibleBox widgets don't talk to each other here. They are just demonstrating 3 individual widgets.

Let me know if I should've added tests to this PR, and if so what kind / to what extent. Based on our timeline vs. our current progress I would like to only add ones that are critical.
![image](https://user-images.githubusercontent.com/12690133/112918835-afa3da80-90ba-11eb-84c2-da39ca0fd4b5.png)
![Mar-29-2021 18-13-19](https://user-images.githubusercontent.com/12690133/112918754-83885980-90ba-11eb-8be6-ad10982cda48.gif)

**Pull request recommendations:**
- [x] Name your pull request _your-development-type/short-description_. Ex: _feature/read-tiff-files_
- [x] Link to any relevant issue in the PR description. Ex: _Resolves [gh-12], adds tiff file format support_
- [x] Provide context of changes.
- [ ] Provide relevant tests for your feature or bug fix.
- [x] Provide or update documentation for any feature added by your pull request.

Thanks for contributing!



Discussion / hints:

Fix patch:
diff --git a/napari_aicssegmentation/_dock_widget.py b/napari_aicssegmentation/_dock_widget.py
index 4d5c1b44..296fbe45 100644
--- a/napari_aicssegmentation/_dock_widget.py
+++ b/napari_aicssegmentation/_dock_widget.py
@@ -14,7 +14,8 @@
     QWidget
 )
 
-from napari_aicssegmentation._custom_widgets import warning_message, form_layout
+from napari_aicssegmentation.widgets.collapsible_box import CollapsibleBox
+from napari_aicssegmentation._helper_functions import warning_message, form_layout
 from napari_aicssegmentation._style_constants import (
     PAGE_WIDTH, 
     PAGE_CONTENT_WIDTH, 
@@ -95,6 +96,8 @@ def set_page_layout(self):
             self.page.layout().addWidget(widget)
 
         self.set_step_3_layout()
+        self.add_collapsible_boxes()
+
         self.page.layout().addStretch()
 
     """
@@ -166,6 +169,30 @@ def set_step_3_layout(self, enabled=False):
             if enabled is False:
                 button.setDisabled(True)
             self.page.layout().addWidget(button, alignment=Qt.AlignCenter)
+    
+    """ 
+    Add CollapsibleBox widgets in 3 different initial states (enabled and closed, enabled
+    and open, and disabled) to the bottom of the window as examples
+    """
+    def add_collapsible_boxes(self):
+        layout_1 = QVBoxLayout()
+        layout_1.addWidget(QLabel("This is a label"))
+        layout_1.addWidget(QPushButton("This is a button"))
+        self.page.layout().addWidget(
+            CollapsibleBox("<span>1.&nbsp;Intensity normalization</span>", layout_1, isOpen=False)
+        )
+
+        layout_2 = QVBoxLayout()
+        layout_2.addWidget(QLabel("This is a label"))
+        layout_2.addWidget(QPushButton("This is a button"))
+        self.page.layout().addWidget(CollapsibleBox("<span>2.&nbsp;Smoothing</span>", layout_2))
+
+        layout_3 = QVBoxLayout()
+        layout_3.addWidget(QLabel("This is a label"))
+        layout_3.addWidget(QPushButton("This is a button"))
+        box_3 = CollapsibleBox("<span>3.&nbsp;2D filament filter</span>", layout_3, isOpen=False)
+        box_3.setDisabled(True)
+        self.page.layout().addWidget(box_3)
 
 
 @napari_hook_implementation
diff --git a/napari_aicssegmentation/_custom_widgets.py b/napari_aicssegmentation/_helper_functions.py
similarity index 94%
rename from napari_aicssegmentation/_custom_widgets.py
rename to napari_aicssegmentation/_helper_functions.py
index 0e109a7b..8ee147aa 100644
--- a/napari_aicssegmentation/_custom_widgets.py
+++ b/napari_aicssegmentation/_helper_functions.py
@@ -1,3 +1,5 @@
+# TODO: Most if not all of these functions should be converted to classes and moved to "widgets/"
+
 from pathlib import Path
 
 from qtpy.QtCore import Qt
diff --git a/napari_aicssegmentation/_style_constants.py b/napari_aicssegmentation/_style_constants.py
index f76125d2..5e6bf6c0 100644
--- a/napari_aicssegmentation/_style_constants.py
+++ b/napari_aicssegmentation/_style_constants.py
@@ -45,12 +45,42 @@
         color: #44494c;
     }
 
-    QLabel#btnInstructionsDisabled, QLabel#step3InstructionsDisabled {
+    QLabel#step3InstructionsDisabled {
         color: #44494c;
     }
 
     QPushButton:disabled { 
         background-color: #333941;
     }
+
+    CollapsibleBox .QFrame {
+        background-color: #5a626d;
+        border-radius: 2px;
+        border-color: #6d7075;
+        border-width: 0.5px;
+        border-style: solid;
+    }
+
+    CollapsibleBox QFrame:enabled#titleBoxClosed {
+        background-color: #414852;
+        border-color: #414852;
+    }
+
+    CollapsibleBox QFrame#titleBoxClosed:hover {
+        background-color: #5a626e;
+        border-color: #5a626e;
+    }
+
+    CollapsibleBox QFrame:disabled {
+        background-color: #333942;
+        border-radius: 2px;
+        border-color: #333942;
+        color: #7C848A;
+    }
+
+    CollapsibleBox .QFrame#contentBox {
+        background-color: #30353d;
+        border-top: 0px;
+    }
     """
 )
diff --git a/napari_aicssegmentation/assets/icons/gear.svg b/napari_aicssegmentation/assets/icons/gear.svg
new file mode 100644
index 00000000..887bcc6d
--- /dev/null
+++ b/napari_aicssegmentation/assets/icons/gear.svg
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg"
+    xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="16px" height="16px" viewBox="0 0 122.88 122.878"
+    enable-background="new 0 0 122.88 122.878" xml:space="preserve">
+    <g>
+        <path fill-rule="evenodd" clip-rule="evenodd"
+            d="M101.589,14.7l8.818,8.819c2.321,2.321,2.321,6.118,0,8.439l-7.101,7.101 c1.959,3.658,3.454,7.601,4.405,11.752h9.199c3.283,0,5.969,2.686,5.969,5.968V69.25c0,3.283-2.686,5.969-5.969,5.969h-10.039 c-1.231,4.063-2.992,7.896-5.204,11.418l6.512,6.51c2.321,2.323,2.321,6.12,0,8.44l-8.818,8.819c-2.321,2.32-6.119,2.32-8.439,0 l-7.102-7.102c-3.657,1.96-7.601,3.456-11.753,4.406v9.199c0,3.282-2.685,5.968-5.968,5.968H53.629 c-3.283,0-5.969-2.686-5.969-5.968v-10.039c-4.063-1.232-7.896-2.993-11.417-5.205l-6.511,6.512c-2.323,2.321-6.12,2.321-8.441,0 l-8.818-8.818c-2.321-2.321-2.321-6.118,0-8.439l7.102-7.102c-1.96-3.657-3.456-7.6-4.405-11.751H5.968 C2.686,72.067,0,69.382,0,66.099V53.628c0-3.283,2.686-5.968,5.968-5.968h10.039c1.232-4.063,2.993-7.896,5.204-11.418l-6.511-6.51 c-2.321-2.322-2.321-6.12,0-8.44l8.819-8.819c2.321-2.321,6.118-2.321,8.439,0l7.101,7.101c3.658-1.96,7.601-3.456,11.753-4.406 V5.969C50.812,2.686,53.498,0,56.78,0h12.471c3.282,0,5.968,2.686,5.968,5.969v10.036c4.064,1.231,7.898,2.992,11.422,5.204 l6.507-6.509C95.471,12.379,99.268,12.379,101.589,14.7L101.589,14.7z M61.44,36.92c13.54,0,24.519,10.98,24.519,24.519 c0,13.538-10.979,24.519-24.519,24.519c-13.539,0-24.519-10.98-24.519-24.519C36.921,47.9,47.901,36.92,61.44,36.92L61.44,36.92z" fill="#f0f1f2"/>
+    </g>
+</svg>
\ No newline at end of file
diff --git a/napari_aicssegmentation/widgets/__init__.py b/napari_aicssegmentation/widgets/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/napari_aicssegmentation/widgets/collapsible_box.py b/napari_aicssegmentation/widgets/collapsible_box.py
new file mode 100644
index 00000000..6a687dc5
--- /dev/null
+++ b/napari_aicssegmentation/widgets/collapsible_box.py
@@ -0,0 +1,97 @@
+from pathlib import Path
+
+from qtpy.QtGui import QPixmap
+from qtpy.QtWidgets import (
+    QFrame,
+    QHBoxLayout,
+    QLabel,
+    QVBoxLayout,
+    QWidget
+)
+
+DIR = Path.cwd() / "napari_aicssegmentation"
+
+
+"""
+A collapsible box widget containing a title box and a content box. The title box can be clicked
+to toggle the visibility of the content box.
+
+Params:
+    title:      String
+    content:    QLayout to be nested inside the content box
+    isOpen:     Boolean, whether the widget is open or collapsed
+"""
+
+
+class CollapsibleBox(QWidget):
+    def __init__(self, title, content, isOpen=True):
+        super().__init__()
+        self.title = title
+        self.content = content
+        self.isOpen = isOpen
+
+        self.layout = QVBoxLayout()
+        self.layout.setContentsMargins(0, 11, 0, 11)
+        self.layout.setSpacing(0)   # No space between title_box and content_box
+        self.setLayout(self.layout)
+
+        self.title_box = self._create_title_box()
+        self.content_box = self._create_content_box()
+
+        self.layout.addWidget(self.title_box)
+        self.layout.addWidget(self.content_box)
+
+    def _create_title_box(self):
+        title_box = QFrame()
+        title_box_layout = QHBoxLayout()
+        title_box_layout.setContentsMargins(9, 9, 9, 9)
+        title_box.setLayout(title_box_layout)
+        title_box.setFixedHeight(40)
+        if self.isOpen is False:
+            title_box.setObjectName("titleBoxClosed")
+
+        title = QLabel(self.title)
+        icon = QLabel()
+        icon.setPixmap(QPixmap(str(DIR / "assets/icons/gear.svg")))
+        title_box_layout.addWidget(title)
+        title_box_layout.addStretch()
+        title_box_layout.addWidget(icon)
+
+        return title_box
+
+    def _create_content_box(self):
+        content_box = QFrame()
+        content_box_layout = QVBoxLayout()
+        content_box_layout.setContentsMargins(9, 9, 9, 9)
+        content_box_layout.addLayout(self.content)
+        content_box.setLayout(content_box_layout)
+        content_box.setObjectName("contentBox")
+
+        if self.isOpen is False:
+            content_box.hide()
+        return content_box
+    
+    def open(self):
+        if self.isOpen is False:
+            self.isOpen = True
+            self.content_box.show()
+            self.title_box.setObjectName("")
+            self.title_box.setStyle(self.title_box.style())
+
+    def close(self):
+        if self.isOpen:
+            self.isOpen = False
+            self.content_box.hide()
+            self.title_box.setObjectName("titleBoxClosed")
+            self.title_box.setStyle(self.title_box.style())
+
+    def toggle(self):
+        if self.isOpen:
+            self.close()
+        else:
+            self.open()
+    
+    # Overwrite default QWidget.mousePressEvent() method
+    def mousePressEvent(self, event):
+        if self.title_box.underMouse():
+            self.toggle()


Test patch:
diff --git a/napari_aicssegmentation/_tests/widgets/__init__.py b/napari_aicssegmentation/_tests/widgets/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/napari_aicssegmentation/_tests/widgets/collapsible_box_test.py b/napari_aicssegmentation/_tests/widgets/collapsible_box_test.py
new file mode 100644
index 00000000..65e6e5e8
--- /dev/null
+++ b/napari_aicssegmentation/_tests/widgets/collapsible_box_test.py
@@ -0,0 +1,68 @@
+import pytest
+from PyQt5.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget
+
+from napari_aicssegmentation.widgets.collapsible_box import CollapsibleBox
+
+class TestCollapsibleBox:
+    def setup_class(self):
+        layout = QVBoxLayout()
+        layout.addWidget(QLabel("This is a label"))
+        layout.addWidget(QPushButton("This is a button"))
+
+        self.collapsible_box = CollapsibleBox("1. Intensity Normalization", layout)
+
+    @pytest.fixture(autouse=True)
+    def setup_qt(self, qapp):
+        # the pytestqt.qapp fixture sets up the QApplication required to run QT code
+        # see https://pytest-qt.readthedocs.io/en/latest/reference.html
+        yield
+
+    def test_close(self):
+        # Arrange - box starts out open
+        assert self.collapsible_box.isOpen is True
+        assert self.collapsible_box.content_box.isHidden() is False
+        assert self.collapsible_box.title_box.objectName() == ""
+
+        # Act
+        self.collapsible_box.close()
+
+        # Assert - box should be closed and contents hidden
+        assert self.collapsible_box.isOpen is False
+        assert self.collapsible_box.content_box.isHidden() is True
+        assert self.collapsible_box.title_box.objectName() == "titleBoxClosed"
+
+    def test_open(self):
+        # Assert - box should be closed and contents hidden
+        assert self.collapsible_box.isOpen is False
+        assert self.collapsible_box.content_box.isHidden() is True
+        assert self.collapsible_box.title_box.objectName() == "titleBoxClosed"
+
+        # Act
+        self.collapsible_box.open()
+
+        # Assert - box should be open and contents visible
+        assert self.collapsible_box.isOpen is True
+        assert self.collapsible_box.content_box.isHidden() is False
+        assert self.collapsible_box.title_box.objectName() == ""
+    
+    def test_toggle(self):
+        # Arrange - Box starts out open
+        assert self.collapsible_box.isOpen is True
+        assert self.collapsible_box.content_box.isHidden() is False
+        assert self.collapsible_box.title_box.objectName() == ""
+
+        # Act
+        self.collapsible_box.toggle()
+
+        # Assert - box should be closed and contents hidden
+        assert self.collapsible_box.isOpen is False
+        assert self.collapsible_box.content_box.isHidden() is True
+        assert self.collapsible_box.title_box.objectName() == "titleBoxClosed"
+
+        # Act
+        self.collapsible_box.toggle()
+
+        # Assert - box should be open and contents visible
+        assert self.collapsible_box.isOpen is True
+        assert self.collapsible_box.content_box.isHidden() is False
+        assert self.collapsible_box.title_box.objectName() == ""