pythonpyqtpyqt5qtreeview

How to sort items in Qtreeview in pyqt5?


How to sort items in QTreeview by the following concepts ?

self.model_01 = self.model()
for i in range(self.model_01.rowCount()):
    if self.itemData(i) is None:
        self.setItemData(i, i)

...


def ascending_order(self):
    self.model_01.setSortRole(Qt.DisplayRole)
    self.model_01.sort(self.modelColumn(), Qt.AscendingOrder)


def descending_order(self):
    self.model_01.setSortRole(Qt.DisplayRole)
    self.model_01.sort(self.modelColumn(), Qt.DescendingOrder)


def given_order(self):
    print("given order")
    self.model_01.setSortRole(Qt.UserRole)
    self.model_01.sort(self.modelColumn(), Qt.AscendingOrder)


def reverse_order(self):
    print("reverse order")
    self.model_01.setSortRole(Qt.UserRole)
    self.model_01.sort(self.modelColumn(), Qt.DescendingOrder)

using this code , I can able to sort item in ascending order as well as in descending order in Qt.DisplayRole.

But in Qt.UserRole, I cant able to sort items.

How to sort items in ascending(Original order) or in reverse of original order ?

Update - Minimal reproducible example

from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *

data = {
    "select all": {
        'Group 1': ['item11', 'item12'],
        'Group 3': ['item32', 'item31'],
        'Group 2': ['item21', 'item22'],
        'Group 4': ['item41', 'item42'],
    }
}
class MyModel(QStandardItemModel):
    def __init__(self):
        super().__init__()

        self.root_text, self.parent_text, self.child_text = [], [], []

        for root_key, root_value in data.items():
            if root_key not in self.root_text:
                self.root_text.append(root_key)
            root_item = QStandardItem()
            root_item.setData(root_key, role=Qt.DisplayRole)
            root_item.setCheckable(True)
            self.appendRow(root_item)

            for parent_key, parent_value in root_value.items():
                if parent_key not in self.parent_text:
                    self.parent_text.append(parent_key)
                parent_item = QStandardItem()
                parent_item.setData(parent_key, role=Qt.DisplayRole)
                parent_item.setCheckable(True)
                root_item.appendRow(parent_item)

                for child_value in parent_value:
                    if child_value not in self.child_text:
                        self.child_text.append(child_value)
                    child_item = []
                    child_item = QStandardItem()
                    child_item.setData(child_value, role=Qt.DisplayRole)
                    child_item.setCheckable(True)
                    parent_item.appendRow(child_item)

        self.itemChanged.connect(self.update_children)

    def update_children(self, item, fromUser=True):
        print(item,"item")
        if fromUser:
            # temporarily disconnect to avoid recursion
            self.itemChanged.disconnect(self.update_children)
        for i in range(item.rowCount()):
            child = item.child(i)
            child.setCheckState(item.checkState())
            # explicitly call update_children
            self.update_children(child, False)

        if fromUser:
            root = self.invisibleRootItem()
            parent = item.parent() or root
            while True:
                count = parent.rowCount()
                checked = 0
                for i in range(count):
                    state = parent.child(i).checkState()
                    if state == Qt.Checked:
                        checked += 1
                    elif state == Qt.PartiallyChecked:
                        parent.setCheckState(Qt.PartiallyChecked)
                        break
                else:
                    if not checked:
                        parent.setCheckState(Qt.Unchecked)
                    elif checked == count:
                        parent.setCheckState(Qt.Checked)
                    else:
                        parent.setCheckState(Qt.PartiallyChecked)

                if parent == root:
                    break
                parent = parent.parent() or root

            self.itemChanged.connect(self.update_children)

class MyCombo(QComboBox):
    clickedData = None

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.treeView = QTreeView()
        self.treeView.setHeaderHidden(True)
        self.setView(self.treeView)
        self.treeView.viewport().installEventFilter(self)

        # Qmenu intilize
        self.menu = QMenu()
         
        self.setContextMenuPolicy(Qt.CustomContextMenu)
        self.customContextMenuRequested.connect(self.cntxt_menu)
        self.RightClickMenu()

        self.delegate = QStyledItemDelegate(self.treeView)

    def eventFilter(self, obj, event):
        if (
                event.type() == event.MouseButtonPress
                and event.button() == Qt.LeftButton
        ):
            index = self.treeView.indexAt(event.pos())

            if index.isValid():
                opt = self.treeView.viewOptions()
                opt.rect = self.treeView.visualRect(index)
                self.delegate.initStyleOption(opt, index)
                checkRect = self.style().subElementRect(
                    QStyle.SE_ItemViewItemCheckIndicator, opt, self.treeView)
                if checkRect.contains(event.pos()):
                    self.clickedData = index, checkRect
        elif event.type() == event.MouseButtonRelease:
            if event.button() == Qt.LeftButton and self.clickedData:
                index = self.treeView.indexAt(event.pos())
                pressIndex, checkRect = self.clickedData
                if index == pressIndex and event.pos() in checkRect:
                    state = index.data(Qt.CheckStateRole)
                    if state == Qt.Checked:
                        state = Qt.Unchecked
                    else:
                        state = Qt.Checked
                    self.model().setData(index, state, Qt.CheckStateRole)
                self.clickedData = None
            return True
        elif (
                event.type() == event.MouseButtonPress
                and event.button() == Qt.LeftButton
        ):
            index = self.treeView.indexAt(event.pos())
            state = index.data(Qt.CheckStateRole)
            if state == Qt.Checked:
                state = Qt.Unchecked
            else:
                state = Qt.Checked
            self.model().setData(index, state, Qt.CheckStateRole)
            self.treeView.viewport().update()
            self.clickedData = None
            return True
        return super().eventFilter(obj, event)

    def showPopup(self):
        self.treeView.expandAll()
        width = self.treeView.sizeHintForColumn(0)
        maxCount = self.maxVisibleItems()
        index = self.model().index(0, 0, self.rootModelIndex())
        visible = 0
        while index.isValid():
            visible += 1
            index = self.treeView.indexBelow(index)
            if visible > maxCount:
                # the visible count is higher than the maximum, so the vertical
                # scroll bar will be shown and we have to consider its width.
                # Note that this does NOT consider styles that use "transient"
                # scroll bars, which are shown *within* the content of the view,
                # as it happens on macOs; see QStyle.styleHint() and
                # QStyle::SH_ScrollBar_Transient
                width += self.treeView.verticalScrollBar().sizeHint().width()
                break
        self.treeView.setMinimumWidth(width)
        super().showPopup()

    def RightClickMenu(self):
        self.menu.clear()
        self.ascending_action = QAction('Ascending',self)
        self.menu.addAction(self.ascending_action)
        self.ascending_action.triggered.connect(self.ascending_order)

        self.descending_action = QAction('Descending')
        self.descending_action.triggered.connect(self.descending_order)
        self.menu.addAction(self.descending_action)

        self.original_action = QAction('Original Order')
        self.original_action.triggered.connect(self.original_order)
        self.menu.addAction(self.original_action)

        self.reverse_action = QAction('Reverse order')
        self.reverse_action.triggered.connect(self.reverse_order)
        self.menu.addAction(self.reverse_action)

    def cntxt_menu(self,pos):
        self.model_01 = self.model()
        self.menu.exec_(self.mapToGlobal(pos))

    def ascending_order(self):
        self.model_01.setSortRole(Qt.DisplayRole)
        self.model_01.sort(self.modelColumn(),Qt.AscendingOrder)

    def descending_order(self):
        self.model_01.setSortRole(Qt.DisplayRole)
        self.model_01.sort(self.modelColumn(), Qt.DescendingOrder)

    def original_order(self):
        print("given order")
        self.model_01.setSortRole(Qt.UserRole)
        # self.model_01.sort(0, Qt.AscendingOrder)
        self.model_01.sort(0,Qt.AscendingOrder)


    def reverse_order(self):
        print("reverse order")
        self.model_01.setSortRole(Qt.UserRole)
        self.model_01.sort(self.modelColumn(), Qt.DescendingOrder)

class MainWindow(QWidget):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("QCombobox")
        self.comboBox = MyCombo()
        self.comboBox.setEditable(False)
        self.model = MyModel()
        self.comboBox.setModel(self.model)

        self.vbox = QVBoxLayout()
        self.setLayout(self.vbox)
        self.vbox.addWidget(self.comboBox)

if __name__ == '__main__':
    import sys
    app = QApplication(sys.argv)
    ex = MainWindow()
    ex.show()
    sys.exit(app.exec_())

Solution

  • Qt already provides such a mechanism with standard views: both QTableView and QTreeView provide a sortByColumn() function. The assumption is, as long as the model supports it (as explained in the docs), using -1 for the column should revert to the original layout.

    Unfortunately, while it generally works on QTableView, it seems that there is some inconsistency with QTreeView and QStandardItemModel (I did some basic testing, but I have not been able to find the cause yet; there is a couple of reports in the Qt bug report system, but they seem to be still unresolved).

    Not all is lost, though: as long as the insertion order is consistent (see the notes below), we can use a custom user role whenever a new item is added to the model, including the very first item of any parent (including top level ones).

    In order to do that, we need to connect the rowsInserted signal before any item (row) is created, and properly set the user role starting from the first row up to the last one of the row count of the parent (in order to correctly update the indexes of all following items in case an item is inserted).

    class MyModel(QStandardItemModel):
        def __init__(self):
            super().__init__()
    
            self.rowsInserted.connect(self.updateInsertionOrder)
            # ...
    
        def updateInsertionOrder(self, parent, first, last):
            with QSignalBlocker(self):
                for row in range(first, self.rowCount(parent)):
                    self.setData(self.index(row, 0, parent), row, Qt.UserRole)
    

    Note that: