From 789b98f8920d2302f7ca198ceb6bc50619d5a05a Mon Sep 17 00:00:00 2001 From: Mustafa Al Ibrahim Date: Sat, 20 Nov 2021 17:27:26 +0300 Subject: [PATCH] QCollapsible for Collapsible Section Control (#37) * Update changelog to ingnore virtual environment * wip * wip * Working animation * WIP Implement tests * All tests are passing * convert to camalCase * Change function name to match functionality * convert pyside to qtcompat * move animation utils to main module * remove seperators * protect util functions * add example * remove seperators from test file * suggestions * Passing tests and ability to initialize expansion * Ensure that the test will be passed in any screen resolution * replace quick functions with parameters * Update src/superqt/collapsible/_collapsible.py Fix initial text Co-authored-by: Talley Lambert * Update src/superqt/collapsible/_collapsible.py Remote WindowFlags to prevent compatiblity issue. Co-authored-by: Talley Lambert * merge internal expand and collapse into one function * Update src/superqt/collapsible/_collapsible.py * Update tests/test_collapsible.py Co-authored-by: Talley Lambert --- .gitignore | 1 + examples/collapsible.py | 15 +++ src/superqt/__init__.py | 2 + src/superqt/collapsible/__init__.py | 3 + src/superqt/collapsible/_collapsible.py | 107 ++++++++++++++++++ .../right-arrow-black-triangle-sharp.png | Bin 0 -> 1431 bytes tests/test_collapsible.py | 66 +++++++++++ 7 files changed, 194 insertions(+) create mode 100644 examples/collapsible.py create mode 100644 src/superqt/collapsible/__init__.py create mode 100644 src/superqt/collapsible/_collapsible.py create mode 100644 src/superqt/collapsible/resources/right-arrow-black-triangle-sharp.png create mode 100644 tests/test_collapsible.py 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 0000000000000000000000000000000000000000..6d1fcfe5b967f96bb66d13d1835acf01aed23307 GIT binary patch literal 1431 zcmZ`(Yfuwc6pjeWq6y%pgNiAXU?L9{4TQK*DB)pZLZA^W2#Q-oe4y?OsfvPaWFb}& zttf&JbZsbFs6VqocnE} zY(3p&sSAxpql-l`auENuyYoD-8oC1(fG{UlzFtVXZeC^sz%e^2K8i-WaeBcF>I5)^ ziBfZEG!J*X&w0Rk6G@{v1&U*$k`8Sh?L+qs#xaXNKX|P_@fS;M?ewnEdofG@^FJ$M zJn!mUr*|^E8=Gqg1#LcH->5qZB9zeVn~t$D7?D!!S9^u3-)15)!n9p(9mE-h5_DKb z1zly1w?nLXWx}Jugvnd3e<-68(_JPXL97!S41cRFLf8ECUFK=tW2)7j9g}JYKB+`Hbf<> zEhDjhnint7r2KOe!s*Uij!L=I2(eZBu28#8;4UvV^7;#*7f zyP~LA=jw8Ns8Zi@%o64<_+ru2T}PR#@b+=NO-!AFs$KA*xjI)bah_!;c`z~TCg3fa;wpK%XVD&lfE2z$sXO~j z2-3|QkT%%>D_z#AAE~neGCw4qa!^edyKb?*BDKVVWi_Gcfas&ct$OtKYe19+c!J_^ zdxz>qDcC#Es#=BZY)rGK`36+@7I>C%$Qe|6OvG8W8U5N&(BZu65jJ*cCQDzrn76{>hj`?E#@I;hDoez2`#+u~wvdk{v6T6lq(DGxv zz+IWU9LaiRvb>nB`JMPOIo1X4yckK|(&&-sP z;pZ5??oJ@XSF+ALs@CSEBEIz@s4fjzQNISwO+fP1le6jqquZ?3+BhtYnuqpoL8M`PbYmQnI2ej@*Y-WA-VY(&uagAh+j16VWu zFdxiF-H`BzMzE$-vb`Yo>7eCAs(aG7@7KtsTJW2Q}N`Dzzg@ zrtBZcv*w(`j|gfyDHlEBq=uFjPhvNiPy1oM;_R{TJ;q|Qykb2!XN8Yb>p^4jm-4HPGBPpAWuhPQJRup0 zxTrUA5b$i<> NajY!nx^UaE{{gB!X5s(< literal 0 HcmV?d00001 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