Search code examples
python-3.xpyqt5qlistwidgetqlistwidgetitem

How to highlight an item present in Qlistwidget which is under a collapsed Qtoolbutton


Building on to an awesome answer from here by eyllanesc, I have added QListWidget to the collapsible box.

There is a text box below where user will provide search string and my aim is to highlight that item in the QListWidget (if present).

The current code works fine ie it will highlight the text and scroll it to top if the QToolButton is already expanded but it will just open and highlight the item but wont scroll it to top if it is not already expanded. (So the user now doesn't know if he has found the item or not as he cant see it highlighted.) Strange thing is if I press enter again then it will scroll it to top.

I tried various things like making the QlistWidget active, in focus etc but didnt help.

Please tell me what I am missing so that I dont need to press Enter twice in case the QToolButton is not expanded already.

EDIT: Removing the animation part from code as suggested.


import time
from PyQt5 import QtCore
from PyQt5.QtWidgets import QMainWindow, QListWidget, QLabel, QApplication, QVBoxLayout, \
    QWidget, QSizePolicy, QToolButton, QScrollArea, QFrame, QDockWidget, QLineEdit, QHBoxLayout, QAbstractItemView

collapsed_list = ['What', 'should', 'be', 'done', 'to', 'fix', 'this', 'issue?', 'I', 'am', 'confused']


class CollapsibleDemo(QWidget):
    def __init__(self, title="", parent=None):
        super(CollapsibleDemo, self).__init__(parent)
        self.toggle_button = QToolButton(
            text=title, checkable=True, checked=False
        )
        self.toggle_button.setSizePolicy(
            QSizePolicy.Expanding, QSizePolicy.Fixed
        )
        self.toggle_button.setToolButtonStyle(
            QtCore.Qt.ToolButtonTextBesideIcon
        )
        self.toggle_button.setArrowType(QtCore.Qt.RightArrow)
        self.toggle_button.pressed.connect(self.on_pressed)

        self.content_area = QScrollArea(
            maximumHeight=0, minimumHeight=0
        )
        self.content_area.setSizePolicy(
            QSizePolicy.Expanding, QSizePolicy.Fixed
        )

        self.content_area.setFrameShape(QFrame.NoFrame)

        lay = QVBoxLayout(self)
        lay.setSpacing(0)
        lay.setContentsMargins(10, 10, 1, 1)
        lay.addWidget(self.toggle_button)
        lay.addWidget(self.content_area)


    @QtCore.pyqtSlot()
    def on_pressed(self):
        checked = self.toggle_button.isChecked()
        self.toggle_button.setArrowType(
            QtCore.Qt.DownArrow if not checked else QtCore.Qt.RightArrow
        )

        if not checked:
            self.content_area.setMaximumHeight(self.content_height + self.collapsed_height)
        else:
            self.content_area.setMaximumHeight(0)


    def setContentLayout(self, layout):
        self.content_area.setLayout(layout)
        self.collapsed_height = (
                self.sizeHint().height() - self.content_area.maximumHeight()
        )
        self.content_height = layout.sizeHint().height()

    def set_text(self, title):
        self.toggle_button.setText(title)


class Try(QMainWindow):

    def __init__(self, ):
        super().__init__()
        self.width = 800
        self.height = 800
        self.init_ui()

    def init_ui(self):
        self.resize(self.width, self.height)
        self.create_background()
        self.add_collapsed_list_box()
        self.add_find_text_box()
        self.vlay.addStretch()
        self.find_text_box.setFocus()

    def create_background(self):
        dock = QDockWidget()
        self.setCentralWidget(dock)
        dock.setFeatures(QDockWidget.NoDockWidgetFeatures)
        scroll = QScrollArea()
        dock.setWidget(scroll)
        content = QWidget()
        scroll.setWidget(content)
        scroll.setWidgetResizable(True)
        self.vlay = QVBoxLayout(content)
        self.vlay.setSpacing(10)

    def add_collapsed_list_box(self):
        self.box = CollapsibleDemo(f"Whats inside here!")
        self.vlay.addWidget(self.box)
        lay_diff = QVBoxLayout()

        self.qlist = QListWidget()
        self.qlist.addItems(collapsed_list)

        lay_diff.addWidget(self.qlist)
        self.box.setContentLayout(lay_diff)


    def add_find_text_box(self):
        self.find_text_box = QLineEdit("am")

        find_label = QLabel("    Search text:   ")
        enter_label = QLabel(" (Press Enter)")
        hlayout = QHBoxLayout()
        hlayout.addWidget(find_label)
        hlayout.addWidget(self.find_text_box)
        hlayout.addWidget(enter_label)
        hlayout.addStretch(3)
        self.vlay.addLayout(hlayout)
        self.find_text_box.returnPressed.connect(self.find_selected)

    def find_selected(self):
        user_text = self.find_text_box.text()

        if user_text in collapsed_list:
            if not self.box.toggle_button.isChecked():
                self.box.on_pressed()
                self.box.toggle_button.setChecked(True)
            item = self.qlist.findItems(user_text, QtCore.Qt.MatchRegExp)[0]
            item.setSelected(True)
            self.qlist.scrollToItem(item, QAbstractItemView.PositionAtTop)


if __name__ == "__main__":
    app = QApplication([])
    window = Try()
    window.show()
    app.exec()


Solution

  • There are two problems:

    1. animations are asynchronous: when they are started, control is immediately returned, so any function called after animation.start() is instantly executed; this means that at that point the value of the animation is still the start value;
    2. item views (and scroll areas in general) require some time in order to properly update their scroll bars; even if the box is being collapsed immediatly (without animation), scrollToItem will not work, because the scroll area has not layed out its contents yet;

    In order to solve the problem, the solution is to delay the scrollToItem call using a singleShot QTimer.

    If an animation is being used, it's mandatory that you wait for the animation to end, because the scroll bars are being updated along with the box size.

    A possible solution is to create a local function that delays the scrollToItem and, if the box is collapsed, call it only whenever the animation is completed by connecting to the animation finished signal, and then disconnect it (this is very important!).

    def find_selected(self):
        user_text = self.find_text_box.text()
    
        if user_text in collapsed_list:
            collapsed = not self.box.toggle_button.isChecked()
            if collapsed:
                self.box.on_pressed()
                self.box.toggle_button.setChecked(True)
            item = self.qlist.findItems(user_text, QtCore.Qt.MatchRegExp)[0]
            item.setSelected(True)
    
            def scrollTo():
                QtCore.QTimer.singleShot(1, 
                    lambda: self.qlist.scrollToItem(item, QAbstractItemView.PositionAtTop))
                if collapsed:
                    # disconnect the function!!!
                    self.box.toggle_animation.finished.disconnect(scrollTo)
            if collapsed:
                self.box.toggle_animation.finished.connect(scrollTo)
            else:
                scrollTo()
    

    If you're not interested in the animation, then it's even easier:

    class CollapsibleDemo(QWidget):
        def __init__(self, title="", parent=None):
            # ...
            # remove all the animations in here
    
        @QtCore.pyqtSlot()
        def on_pressed(self):
            checked = self.toggle_button.isChecked()
            self.toggle_button.setArrowType(
                QtCore.Qt.DownArrow if not checked else QtCore.Qt.RightArrow
            )
            if checked:
                self.setFixedHeight(self.collapsed_height)
                self.content_area.setFixedHeight(0)
            else:
                self.setFixedHeight(self.expanded_height)
                self.content_area.setFixedHeight(self.content_height)
    
        def setContentLayout(self, layout):
            self.content_area.setLayout(layout)
            self.collapsed_height = self.sizeHint().height() - self.content_area.maximumHeight()
            self.content_height = layout.sizeHint().height()
            self.expanded_height = self.collapsed_height + self.content_height
    
        def set_text(self, title):
            self.toggle_button.setText(title)
    
    
    class Try(QMainWindow):
        def find_selected(self):
            user_text = self.find_text_box.text()
    
            if user_text in collapsed_list:
                if not self.box.toggle_button.isChecked():
                    self.box.on_pressed()
                    self.box.toggle_button.setChecked(True)
                item = self.qlist.findItems(user_text, QtCore.Qt.MatchRegExp)[0]
                item.setSelected(True)
                QtCore.QTimer.singleShot(1, lambda:
                    self.qlist.scrollToItem(item, QAbstractItemView.PositionAtTop))
    

    As explained in the comments, animations make sense when their duration is at least 50-100ms; using a duration too short makes the animation useless (computers are not able to show the effect and our eyes would not be able to see it anyway) and only complicates things (you have to wait for the animation to end).