diff --git a/.gitignore b/.gitignore index c46c0b6..bc8255a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ __pycache__/ # Distribution / packaging .Python env/ +.venv/ build/ develop-eggs/ dist/ diff --git a/examples/collapsible.py b/examples/collapsible.py new file mode 100644 index 0000000..ea3401d --- /dev/null +++ b/examples/collapsible.py @@ -0,0 +1,15 @@ +"""Example for QCollapsible""" +from PySide2.QtWidgets import QLabel +from superqt import QCollapsible +from superqt.qtcompat.QtWidgets import QApplication, QPushButton, QLabel + +app = QApplication([]) + +collapsible = QCollapsible("Advanced analysis") +collapsible.addWidget(QLabel("This is the inside of the collapsible frame")) +for i in range(10): + collapsible.addWidget(QPushButton(f"Content button {i + 1}")) + +collapsible.expand(animate=False) +collapsible.show() +app.exec_() diff --git a/src/superqt/__init__.py b/src/superqt/__init__.py index 2ab73fb..82760bd 100644 --- a/src/superqt/__init__.py +++ b/src/superqt/__init__.py @@ -6,6 +6,7 @@ except ImportError: from ._eliding_label import QElidingLabel +from .collapsible import QCollapsible from .combobox import QEnumComboBox from .sliders import ( QDoubleRangeSlider, @@ -33,4 +34,5 @@ __all__ = [ "QMessageHandler", "QRangeSlider", "QEnumComboBox", + "QCollapsible", ] diff --git a/src/superqt/collapsible/__init__.py b/src/superqt/collapsible/__init__.py new file mode 100644 index 0000000..e1fe134 --- /dev/null +++ b/src/superqt/collapsible/__init__.py @@ -0,0 +1,3 @@ +from ._collapsible import QCollapsible + +__all__ = ["QCollapsible"] diff --git a/src/superqt/collapsible/_collapsible.py b/src/superqt/collapsible/_collapsible.py new file mode 100644 index 0000000..a42297c --- /dev/null +++ b/src/superqt/collapsible/_collapsible.py @@ -0,0 +1,107 @@ +"""A collapsible widget to hide and unhide child widgets""" +from typing import Optional + +from ..qtcompat.QtCore import QAbstractAnimation, QEasingCurve, QPropertyAnimation, Qt, QMargins +from ..qtcompat.QtWidgets import QFrame, QPushButton, QVBoxLayout, QWidget + + +class QCollapsible(QFrame): + """ + A collapsible widget to hide and unhide child widgets. + + Based on https://stackoverflow.com/a/68141638 + """ + + _EXPANDED = "▼ " + _COLLAPSED = "▲ " + + def __init__(self, title: str = "", parent: Optional[QWidget] = None): + super().__init__(parent) + self._locked = False + + self._toggle_btn = QPushButton(self._COLLAPSED + title) + self._toggle_btn.setCheckable(True) + self._toggle_btn.setChecked(False) + self._toggle_btn.setStyleSheet("text-align: left; background: transparent;") + self._toggle_btn.clicked.connect(self._toggle) + + # frame layout + self.setLayout(QVBoxLayout()) + self.layout().setAlignment(Qt.AlignmentFlag.AlignTop) + self.layout().addWidget(self._toggle_btn) + + # Create animators + self._animation = QPropertyAnimation(self) + self._animation.setPropertyName(b"maximumHeight") + self._animation.setStartValue(0) + self.setDuration(300) + self.setEasingCurve(QEasingCurve.Type.InOutCubic) + + # default content widget + _content = QWidget() + _content.setLayout(QVBoxLayout()) + _content.setMaximumHeight(0) + _content.layout().setContentsMargins(QMargins(5,0,0,0)) + self.setContent(_content) + + def setText(self, text: str): + current = self._toggle_btn.text()[: len(self._EXPANDED)] + self._toggle_btn.setText(current + text) + + def setContent(self, content: QWidget): + self._content = content + self.layout().addWidget(self._content) + self._animation.setTargetObject(content) + + def content(self) -> QWidget: + return self._content + + def setDuration(self, msecs: int): + self._animation.setDuration(msecs) + + def setEasingCurve(self, easing: QEasingCurve): + self._animation.setEasingCurve(easing) + + def addWidget(self, widget: QWidget): + self._content.layout().addWidget(widget) + + def removeWidget(self, widget: QWidget): + self._content.layout().removeWidget(widget) + + def expand(self, animate: bool = True): + self._expand_collapse(QAbstractAnimation.Direction.Forward, animate) + + def collapse(self, animate: bool = True): + self._expand_collapse(QAbstractAnimation.Direction.Backward, animate) + + def expanded(self): + return self._toggle_btn.isChecked() + + def _expand_collapse(self, direction: QAbstractAnimation.Direction, animate:bool = True): + if self._locked is True: + return + + forward = direction == QAbstractAnimation.Direction.Forward + text = self._EXPANDED if forward else self._COLLAPSED + + self._toggle_btn.setChecked(forward) + self._toggle_btn.setText(text + self._toggle_btn.text()[len(self._EXPANDED) :]) + + if animate: + self._animation.setDirection(direction) + self._animation.setEndValue(self._content.sizeHint().height() + 10) + self._animation.start() + else: + height = 0 if forward==False else (self._content.sizeHint().height() + 10) + self._content.setMaximumHeight(height) + + def _toggle(self): + if self._locked is True: + self._toggle_btn.setChecked(not self._toggle_btn.isChecked) + self.expand() if self._toggle_btn.isChecked() else self.collapse() + + def setLocked(self, locked: bool = True): + self._locked = locked + + def locked(self) -> bool: + return self._locked diff --git a/src/superqt/collapsible/resources/right-arrow-black-triangle-sharp.png b/src/superqt/collapsible/resources/right-arrow-black-triangle-sharp.png new file mode 100644 index 0000000..6d1fcfe Binary files /dev/null and b/src/superqt/collapsible/resources/right-arrow-black-triangle-sharp.png differ diff --git a/tests/test_collapsible.py b/tests/test_collapsible.py new file mode 100644 index 0000000..d7e77e6 --- /dev/null +++ b/tests/test_collapsible.py @@ -0,0 +1,66 @@ +"""A test module for testing collapsible""" + +from superqt import QCollapsible +from superqt.qtcompat.QtCore import QEasingCurve +from superqt.qtcompat.QtWidgets import QPushButton + + +def test_checked_initialization(qtbot): + """Test simple collapsible""" + wdg1 = QCollapsible("Advanced analysis") + wdg1.expand(False) + assert wdg1.expanded() is True + assert wdg1._content.maximumHeight() > 0 + + wdg2 = QCollapsible("Advanced analysis") + wdg1.collapse(False) + assert wdg2.expanded() is False + assert wdg2._content.maximumHeight() == 0 + + +def test_content_hide_show(qtbot): + """Test collapsible with content""" + + # Create child component + collapsible = QCollapsible("Advanced analysis") + for i in range(10): + collapsible.addWidget(QPushButton(f"Content button {i + 1}")) + + collapsible.collapse(False) + assert collapsible.expanded() is False + assert collapsible._content.maximumHeight() == 0 + + collapsible.expand(False) + assert collapsible.expanded() is True + assert collapsible._content.maximumHeight() > 0 + + +def test_locking(qtbot): + """Test locking collapsible""" + wdg1 = QCollapsible() + assert wdg1.locked() is False + wdg1.setLocked(True) + assert wdg1.locked() is True + + # Simulate button press + wdg1._toggle_btn.setChecked(True) + wdg1._toggle() + + assert wdg1.expanded() is False + + +def test_changing_animation_settings(qtbot): + """Quick test for changing animation settings""" + wdg = QCollapsible() + wdg.setDuration(600) + wdg.setEasingCurve(QEasingCurve.Type.InElastic) + assert wdg._animation.easingCurve() == QEasingCurve.Type.InElastic + assert wdg._animation.duration() == 600 + + +def test_changing_content(qtbot): + """Test changing the content""" + content = QPushButton() + wdg = QCollapsible() + wdg.setContent(content) + assert wdg._content == content