Search code examples
pythonpyqtqtablewidgetqtreewidget

How do I sort sort a QTreeWidget or QTableWidget by multiple columns (and how do I sort these columns as numerical values)?


Suppose I have a QTreeWidget with three columns. Two of which with string values and a third with integral values, all of which may appear more than once in each column.

  • Username (str)
  • Product (str)
  • Quantity (int)

I then want to be able to sort these items by either username or product, and to have rows that share these values to be sorted by quantity.

As a side note, I also need to be able to sort the values of the hypothetical quantity as numeric values.

Imagine I had three rows sorted by quantity in the previous example and that those rows had these values:

  • 1
  • 2
  • 10

I would then want these rows to be sorted in that same order, not as they would be if they were sorted as string values:

  • 1
  • 10
  • 2

How do I implement this combination using PyQt5?


Solution

  • Foreword

    I'm not a big fan of long answers, and even loss a fan of long pieces of hard to read code, but these are none the less the solution(s) that I came up with when looking for an answer to this question myself a while back.

    Simple

    This first piece of code is basically a very simplified solution of what I used in the end. It's more efficient and, more importantly, much more easy to read and understand.

    from PyQt5.QtWidgets import QTreeWidget, QTreeWidgetItem
    
    
    class SimpleMultisortTreeWidget(QTreeWidget):
    
        def __init__(self, *a, **k):
            super().__init__(*a, **k)
            self._csort_order = []
            self.header().sortIndicatorChanged.connect(self._sortIndicatorChanged)
        
        def _sortIndicatorChanged(self, n, order):
            try:
                self._csort_order.remove(n)
            except ValueError:
                pass
            self._csort_order.insert(0, n)
            self.sortByColumn(n, order)
    
    
    class SimpleMultisortTreeWidgetItem(QTreeWidgetItem):
        
        def __lt__(self, other):
            corder = self.treeWidget()._csort_order
            return list(map(self .text, corder)) < \
                   list(map(other.text, corder))
    

    Extended

    I also had the need to...

    • Sort some columns as integers and/or decimal.Decimal type objects.
    • Mix ascending and descending order (i.e. mind the Qt.SortOrder set for each column)

    The following example is therefore what I ended up using myself.

    from PyQt5.QtWidgets import QTreeWidget, QTreeWidgetItem
    
    
    class MultisortTreeWidget(QTreeWidget):
        u"""QTreeWidget inheriting object, to be populated by
        ``MultisortTreeWidgetItems``, that allows sorting of multiple columns with
        different ``Qt.SortOrder`` values.
        """
    
        def __init__(self, *arg, **kw):
            r"Pass on all positional and key word arguments to super().__init__"
            super().__init__(*arg, **kw)
            self._csort_corder = []
            self._csort_sorder = []
            self.header().sortIndicatorChanged.connect(
                self._sortIndicatorChanged
            )
    
        def _sortIndicatorChanged(self, col_n, order):
            r"""
            Update private attributes to reflect the current sort indicator.
            
            (Connected to self.header().sortIndicatorChanged)
            
            :param col_n: Sort indicator indicates column with this index to be
            the currently sorted column.
            :type  col_n: int
            :param order: New sort order indication. Qt enum, 1 or 0.
            :type  order: Qt.SortOrder
            """
            # The new and current column number may, or may not, already be in the
            # list of columns that is used as a reference for their individual
            # priority.
            try:
                i = self._csort_corder.index(col_n)
            except ValueError:
                pass
            else:
                del self._csort_corder[i]
                del self._csort_sorder[i]
            # Force current column to have highest priority when sorting.
            self._csort_corder.insert(0, col_n)
            self._csort_sorder.insert(0, order)
            self._csort = list(zip(self._csort_corder,self._csort_sorder))
            # Resort items using the modified attributes.
            self.sortByColumn(col_n, order)
    
    
    class MultisortTreeWidgetItem(QTreeWidgetItem):
        r"""QTreeWidgetÍtem inheriting objects that, when added to a
        MultisortTreeWidget, keeps the order of multiple columns at once. Also
        allows for column specific type sensitive sorting when class attributes
        SORT_COL_KEYS is set.
        """
        
        @staticmethod
        def SORT_COL_KEY(ins, c):
            return ins.text(c)
    
        SORT_COL_KEYS = []
        
        def __lt__(self, other):
            r"""Compare order between this and another MultisortTreeWidgetItem like
            instance.
     
            :param other: Object to compare against.
            :type  other: MultisortTreeWidgetItem.
            :returns: bool
            """
            # Fall back on the default functionality if the parenting QTreeWidget
            # is not a subclass of MultiSortTreeWidget or the SortIndicator has not
            # been changed.
            try:
                csort = self.treeWidget()._csort
            except AttributeError:
                return super(MultisortTreeWidgetItem, self).__lt__(other)
            # Instead of comparing values directly, place them in two lists and
            # extend those lists with values from columns with known sort order.
            order = csort[0][1]
            left  = []
            right = []
            for c, o in csort:
                try:
                    key = self.SORT_COL_KEYS[c]
                except (KeyError, IndexError):
                    key = self.SORT_COL_KEY
                #  Reverse sort order for columns not sorted according to the
                # current sort order indicator.
                if o == order:
                    left .append(key(self , c))
                    right.append(key(other, c))
                else:
                    left .append(key(other, c))
                    right.append(key(self , c))
            return left < right
    

    Usage

    The static method SORT_COL_KEY and the SORT_COL_KEYS class attribute of the above stated MultisortTreeWidgetItem class also allow for other values than those returned by self.text(N) to be used, for example a list returned by self.data().

    The following example sort the text in the rows of the first column as integers and sorts the rows of the third column by the corresponding object in a list returned by self.data(). All other columns is sorted by the item.text() values, sorted as strings.

    class UsageExampleItem(MultisortTreeWidgetItem):
        
        SORT_COL_KEYS = {
            0: lambda item, col: int(item.text(col)),
            2: lambda item, col: item.data()[col],  
            5: lambda item, col: int(item.text(col) or 0) # Empty str defaults to 0
        }
    

    Create a MultisortTreeWidget object and add it to a layout, then create UsageExampleItems and add them to the MultisortTreeWidget.

    This solution "remembers" the columns and sort order used previously. So, if you want to sort the items in a UsageExampleItems widget by the values in the first column, and have rows that share a value to be sorted by the second column among themselves, then you would first click on the header item of the second column and then proceed to click on the header item of the first column.