Search code examples
pythonpyqtpyqt5qtreeview

How to sort items in Qtreeview in pyqt5?


How to sort items in QTreeview by the following concepts ?

  • Ascending Order ( From A to Z, 0 to 9)
  • Descending Order ( From Z to A , 9 to 0)
  • Given Order or Original order by user ( From A to Z )
  • Reverse Order of Original Order ( From Z to A)
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:

    • the above is obviously valid only as long as the first column is considered for sorting: calling sort() using a column higher than the first will obviously not work; eventually consider to implement a custom function (eg. sortByInsertionOrder) that will always call sort() with the first column index;
    • the implementation assumes that new items are always added based on the current sorting: if sort() is called with different arguments (the sort role is not UserRole, the column is not 0 or the order is descending) and a new item is inserted supposing the current sorting, the "original" order will not be consistent: for instance, if the current order is descending and uses the display role and you insert an item at row 0 because it would theoretically follow the current sorting, the result is that the UserRole of any (currently) following indexes will be rewritten, thus losing the original order; a more consistent approach would be to actually use a QSortFilterProxyModel for the view, and apply the sorting to that;
    • I did not add any support for moving rows or row removal; use a similar approach using the related signals (rowsMoved and rowsRemoved) in case that becomes necessary;
    • QStandardItemModel has a further constructor that allows to set the model size before actually adding/setting any item; if you want to add support for such case, you have to check the model rowcount before doing anything and, in case it does have rows, call the above function above accordingly: self.updateInsertionOrder(QModelIndex(), 0, self.rowCount() - 1);