pyapp-kit / superqt

Missing widgets and components for Qt-python
https://pyapp-kit.github.io/superqt/
BSD 3-Clause "New" or "Revised" License
210 stars 38 forks source link

Proposal: Collapsible Section Control #30

Closed MosGeo closed 2 years ago

MosGeo commented 3 years ago

Justification

A lot of scientific plugins require numerous parameters and settings. It is not user-friendly to show all the settings in the plugin window. I propose a collapsible section control that simplifies hiding unimportant/optional settings in a plugin.

Possible solutions

  1. QTreeWidget and QTreeWidgetItem can be used by inserting a composite widget in QTeeWidgetItem. The main issue with this solution is the indentation which does not look great. Also, it does not have a lot of flexibility.
  2. Design a control from scratch using button (to hide and show) and content widget.

Note

There are possible solutions that are available online (e.g., https://github.com/By0ute/pyqt-collapsible-widget). I think having one in superqt would be better than relying on a separate package.

Prototype

Here is a simple prototype with nice animation too. it is based on a solution found in StackOverflow for C++ Qt.

juuUxO3FvN

'''A collapsible widget'''

import sys
from typing import Union
from qtpy.QtWidgets import QLabel, QLayout
from qtpy.QtCore import QAbstractAnimation, QEasingCurve, \
                        QParallelAnimationGroup, QPropertyAnimation
from qtpy.QtWidgets import QPushButton, QVBoxLayout, QApplication, QWidget
from qtpy.QtCore import Qt

# =================================================================================================
class QCollapsibleSection(QPushButton):
    '''
    A collapsible frame for widgets. This is based on simonxeko solution in 
    https://stackoverflow.com/questions/32476006/how-to-make-an-expandable-collapsable-section-widget-in-qt
    '''
    def __init__(self, content:Union[QWidget,QLayout]=None, min_height=None, **kwargs):
        '''Initializes the component'''
        super().__init__(**kwargs)

        if isinstance(content, QLayout):
            content_widget = QWidget()
            content_widget.setLayout(content)
        else:
            content_widget = content

        self.title = self.text()
        self.setCheckable(True)
        self.setStyleSheet("background:rgba(255, 255, 255, 0); text-align:left;")
        self.animator = QParallelAnimationGroup()
        if content is not None:
            self.set_content_widget(content_widget, min_height)
            self.toggle_hidden()
        self.clicked.connect(self.toggle_hidden)
    # ===========================================
    def toggle_hidden(self) -> None:
        '''Toggle the hidden state of the frame'''
        if self.isChecked():
            self.setText("▼ " + self.title)
            self.show_content()
        else:
            self.setText("▲ " + self.title)
            self.hide_content()
    # ===========================================
    def set_content_widget(self, content:QWidget, min_height=None):
        '''Sets the content'''
        animation = QPropertyAnimation(content, b"maximumHeight")
        animation.setStartValue(0)
        animation.setEasingCurve(QEasingCurve.Type.InOutQuad)
        animation.setDuration(300)
        print(content.geometry().height())
        if min_height is None:
            animation.setEndValue(content.geometry().height()+10)
        else:
            animation.setEndValue(min_height)
        # animation.setEndValue(200)
        self.animator.addAnimation(animation)
        if not self.isChecked():
            content.setMaximumHeight(0)
    # ===========================================
    def hide_content(self):
        '''Hides the content'''
        self.animator.setDirection(QAbstractAnimation.Direction.Backward)
        self.animator.start()
    # ===========================================
    def show_content(self):
        '''Show the content'''
        self.animator.setDirection(QAbstractAnimation.Direction.Forward)
        self.animator.start()
# =================================================================================================
if __name__ == "__main__":
    app = QApplication(sys.argv)

    main_widget = QWidget()
    layout = QVBoxLayout()
    layout.setAlignment(Qt.AlignTop)
    extra       = QPushButton(text="Content button")
    collapsible = QCollapsibleSection(text='Advanced analysis', content=extra)

    layout.addWidget(collapsible)
    layout.addWidget(extra)
    layout.addWidget(QLabel("I am a label"))

    main_widget.setLayout(layout)
    main_widget.show()
    sys.exit(app.exec_())
# =================================================================================================
tlambert03 commented 3 years ago

i like it! would be happy to have such a PR

MosGeo commented 3 years ago

@tlambert03 Thanks! ok, I will make it more customizable and follow the guidelines in the repo to create a draft PR for review. Also, I added a screen recording to showcase how it looks inside a napari plugin.

tlambert03 commented 3 years ago

thanks! couple minor suggestions:

MosGeo commented 3 years ago

@tlambert03 To give an update on this one. I now have the basic code working as you wanted with the customizations including the rotation animation. Note that the rotation looks a bit weird in the gif for some reason. The caret is now an icon (as opposed to just symbol before) so I still have to deal with the icon color based on the theme i think. I am a beginner at QT so this is all new. I also have some design decisions that need to be made. Finally, I should add tests. I think I will clean the code a bit and create a pull request to discuss the code.

nl8COYDTXl

tlambert03 commented 3 years ago

Wow! Looks fantastic!

Yeah, feel free to open a PR at any point and I'm happy to help hammer out the final details. Including the styling issues with the caret. (It's a good point)

Thanks!!

MosGeo commented 2 years ago

Issue was addressed by #37 pull request.

MinmoTech commented 2 years ago

I was wondering if info about this could be included in the readme? :)

tlambert03 commented 2 years ago

yes, I'm sorry, this repo needs a proper documentation site now that the scope has broadened significantly

MinmoTech commented 2 years ago

Thank you!