0

There are different ways to change row order in QTableWidget:

  1. by internal move via drag & drop
  2. by separate buttons which shift a selected row up or down by one position

It turned out that these two approaches are not very practical for longer lists and my special purpose. So, I tried to implement the following approach by assigning the new position by changing cell values:

  • the first column holds current position number
  • by editing these numbers I want to assign the new position to this row
  • I want to allow editing only on the first column
  • if an invalid position number is entered (within the range of number of rows) nothing should change
  • if a valid position number is entered the other position numbers in the first column are modified accordingly.
  • then I can get the rearranged rows in new order by clicking on the column header for sorting by the first column.

Example: position numbers 1,2,3,4,5. If I change the value in row3,column1 from 3 to 1, the position numbers in the first column should change as follows:

1 --> 2
2 --> 3
3 --> 1
4 --> 4
5 --> 5

However, it seems I get problems with setEditTriggers(QAbstractItemView.NoEditTriggers) and setEditTriggers(QAbstractItemView.DoubleClicked).

Depending on some different code variations I tried, it looks like I still get an EditTrigger although I think I have disabled EditTriggers via self.setEditTriggers(QAbstractItemView.NoEditTriggers). Or I get RecursionError: maximum recursion depth exceeded while calling a Python object. Or TypeError: '>' not supported between instances of 'NoneType' and 'int'.

I hope I could make the problem clear enough. What am I doing wrong here?

Code: (minimized non-working example. Should be copy & paste & run)

import sys
from PyQt5.QtWidgets import QMainWindow, QApplication, QWidget, QAction, QTableWidget, QTableWidgetItem, QVBoxLayout, QPushButton, QAbstractItemView
from PyQt5.QtCore import pyqtSlot, Qt
import random

class MyTableWidget(QTableWidget):

    def __init__(self):
        super().__init__()
        
        self.setColumnCount(3)
        self.setRowCount(7)
        self.setSortingEnabled(False)
        header = self.horizontalHeader()
        header.setSortIndicatorShown(True)
        header.sortIndicatorChanged.connect(self.sortItems)
        self.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.setEditTriggers(QAbstractItemView.NoEditTriggers)

        self.col_pos = 0
        self.oldPosValue = None
        self.manualChange = False
        self.cellDoubleClicked.connect(self.cell_doubleClicked)
        self.cellChanged.connect(self.cell_changed)
        
    def cell_doubleClicked(self):
        self.setEditTriggers(QAbstractItemView.NoEditTriggers)
        if self.currentColumn() != self.col_pos:    # editing allowed only for this column
            return
        self.setEditTriggers(QAbstractItemView.DoubleClicked)
        try:
            self.oldPosValue = int(self.currentItem().text())
        except:
            pass
        self.manualChange = True
            
    def cell_changed(self):
        if not self.manualChange:
            return
        self.setEditTriggers(QAbstractItemView.NoEditTriggers)
        try:
            newPosValue = int(self.currentItem().text())
        except:
            newPosValue = None
    
        rowChanged = self.currentRow()
        print("Value: {} --> {}".format(self.oldPosValue, newPosValue))
        if newPosValue>0 and newPosValue<=self.rowCount():
            for row in range(self.rowCount()):
                if row != rowChanged:
                    try:
                        value = int(self.item(row,self.col_pos).text())
                        if value<newPosValue:
                            self.item(row,self.col_pos).setData(Qt.EditRole,value+1)
                    except:
                        print("Error")
                        pass
        else:
            self.item(rowChanged,self.col_pos).setData(Qt.EditRole,self.oldPosValue)
            print("New value outside range")
        self.manualChange = True

class App(QWidget):

    def __init__(self):
        super().__init__()
        self.title = 'PyQt5 table'
        self.initUI()
        
    def initUI(self):
        self.setWindowTitle(self.title)
        self.setGeometry(0,0,400,300)
        self.layout = QVBoxLayout()

        self.tw = MyTableWidget()
        self.layout.addWidget(self.tw)

        self.pb_refill = QPushButton("Refill")
        self.pb_refill.clicked.connect(self.on_click_pb_refill)
        self.layout.addWidget(self.pb_refill)
        
        self.setLayout(self.layout) 
        self.show()

    @pyqtSlot()

    def on_click_pb_refill(self):
        self.tw.setEditTriggers(QAbstractItemView.NoEditTriggers)
        for row in range(self.tw.rowCount()):
            for col in range(self.tw.columnCount()):
                if col==0:
                    number = row+1
                else:
                    number = random.randint(1000,9999)
                twi = QTableWidgetItem()
                self.tw.setItem(row, col, twi)
                self.tw.item(row, col).setData(Qt.EditRole,number)

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

Result:

enter image description here

theozh
  • 22,244
  • 5
  • 28
  • 72
  • Why do you disable sorting if you then do the same by connecting the header's sortIndicatorChanged signal? – musicamante Mar 23 '21 at 13:58
  • @musicamante sorry, this is a remainder from earlier versions. Can be removed. – theozh Mar 23 '21 at 14:01
  • Ok. Another question: does reordering be only done by clicking on the header, or also whenever the editing changes? I mean, if I change the value from 3 to 1, should the model sort automatically or should that happen only when manually sorting from the header? – musicamante Mar 23 '21 at 14:06
  • @musicamante Maybe better with manual sorting. I had this problem earlier with automatic sorting during population of a table. That's why I followed the advice to either switch off sorting or do manual sorting: see https://stackoverflow.com/q/62284220/7295599 and https://stackoverflow.com/q/66506588/7295599 – theozh Mar 23 '21 at 14:22

2 Answers2

2

The main problem is that you're trying to disable editing in the wrong way: toggling the edit triggers won't give you a valid result due to the way the view reacts to events.

The recursion error is due to the fact that you are changing data in the signal that reacts to data changes, which clearly is not a good thing to do.

The other problem is related to the current item, which could become None in certain situations.

First of all, the correct way to disable editing of items is by setting the item's flags. This solves another problem you didn't probably found yet: pressing Tab while in editing mode, allows to change data in the other columns.

Then, in order to correctly use the first column to set the order, you should ensure that all other rows get correctly "renumbered". Since doing that also requires setting data in other items, you must temporarily disconnect from the changed signal.

class MyTableWidget(QTableWidget):

    def __init__(self):
        super().__init__()
        
        self.setColumnCount(3)
        self.setRowCount(7)
        self.setSortingEnabled(False)
        header = self.horizontalHeader()
        header.setSortIndicatorShown(True)
        header.sortIndicatorChanged.connect(self.sortItems)
        self.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.setEditTriggers(QAbstractItemView.DoubleClicked)
        self.itemChanged.connect(self.cell_changed)

    def cell_changed(self, item):
        if item.column():
            return
        newRow = item.data(Qt.DisplayRole)
        self.itemChanged.disconnect(self.cell_changed)
        if not 1 <= newRow <= self.rowCount():
            if newRow < 1:
                newRow = 1
                item.setData(Qt.DisplayRole, 1)
            elif newRow > self.rowCount():
                newRow = self.rowCount()
                item.setData(Qt.DisplayRole, self.rowCount())

        otherItems = []
        for row in range(self.rowCount()):
            otherItem = self.item(row, 0)
            if otherItem == item:
                continue
            otherItems.append(otherItem)

        otherItems.sort(key=lambda i: i.data(Qt.DisplayRole))
        for r, item in enumerate(otherItems, 1):
            if r >= newRow:
                r += 1
            item.setData(Qt.DisplayRole, r)
        self.itemChanged.connect(self.cell_changed)

    def setItem(self, row, column, item):
        # override that automatically disables editing if the item is not on the
        # first column of the table
        self.itemChanged.disconnect(self.cell_changed)
        super().setItem(row, column, item)
        if column:
            item.setFlags(item.flags() & ~Qt.ItemIsEditable)
        self.itemChanged.connect(self.cell_changed)

Note that you must also change the function that creates the items and use item.setData before adding the item to the table:

    def on_click_pb_refill(self):
        for row in range(self.tw.rowCount()):
            for col in range(self.tw.columnCount()):
                if col==0:
                    number = row+1
                else:
                    number = random.randint(1000,9999)
                twi = QTableWidgetItem()
                twi.setData(Qt.EditRole, number)
                self.tw.setItem(row, col, twi)
musicamante
  • 41,230
  • 6
  • 33
  • 58
  • Thank you very much for your answer and the explanations. I wasn't aware of `disconnect` which seems essential here. I will test your code and give a feedback asap. – theozh Mar 23 '21 at 14:49
  • It almost works. If I enter a number higher than rowCount I might get some duplicate numbers in the first column. It try to find where this comes from. – theozh Mar 23 '21 at 15:05
  • @theozh sorry, I forgot to check that after I added the range fixup, which obviously required moving the disconnection above; see the updated code. – musicamante Mar 23 '21 at 15:10
  • hmmm, now, when populating the table, I get `AttributeError: 'NoneType' object has no attribute 'data'` in line `rowNumber = rowItem.data(Qt.DisplayRole) - 1` – theozh Mar 23 '21 at 15:28
  • I don't get that error. Have you changed the function that populates the table? Are you using the `setData` on the item *and not* on the table, as explained? – musicamante Mar 23 '21 at 15:33
  • Yes, I'm using your `class MyTableWidget(QTableWidget)` and `on_click_pb_refill(self)` as above. Have I misssed to changes something else somewhere else? I'm using Python 3.6.3, 32 bit, Win10. Maybe this matters? – theozh Mar 23 '21 at 15:54
  • It looks like modifying `on_click_pb_refill(self)` seems to solve the issue. Adding as first line: `self.tw.itemChanged.disconnect(self.tw.cell_changed)` and adding as last line: `self.tw.itemChanged.connect(self.tw.cell_changed)`. I will test further. – theozh Mar 23 '21 at 16:00
  • Well, it shouldn't happen. I suggest you to try my code like it is (the `MyTableWidget` class is complete) and use the `on_click_pb_refill` function *exactly* as above. Also, add a check at the beginning of `cell_changed` so that ignores changes on other columns: `if item.column(): return` – musicamante Mar 23 '21 at 16:31
  • yes, with adding `if item.column(): return` I can now populate the table, however, I still can produce a situation where I get two identical numbers in the first column, which should not happen. I haven't found out yet which numbers to type in where in which sequence to reproduce this situation. – theozh Mar 23 '21 at 19:49
  • @theozh Got it - sorry. I didn't remember that sorting was not automatic, so my previous code was using the assumption that the *model* row of the changed item was the actual "previous" row. I changed the logic, now it just checks all existing items and sets their "row" value based on the new edited value (Qt is smart enough to ignore the new value if it's the same as the current), the trick is to sort the items based on their current value and assign the new "row" based on the iterator. – musicamante Mar 24 '21 at 00:56
  • sorry, still not working. Now I get again the error `AttributeError: 'NoneType' object has no attribute 'data' ` at the line `otherItems.sort(key=lambda i: i.data(Qt.DisplayRole))`. Apparently, some items are still `None`. So, it looks like if you add a check via `if all(otherItems):` then it seems to work. I will further test. – theozh Mar 24 '21 at 04:37
  • @theozh In my last edit I removed by mistake the previous `setItem()` implementation (which also disabled the editing flag), now it's restored. In any case, you could also change `if otherItem == item:` to `if not otherItem or otherItem == item:`, just for safety. – musicamante Mar 24 '21 at 18:35
1

You can use slightly modified QStandardItemModel and QSortFilterProxyModel for that

from PyQt5 import QtWidgets, QtCore, QtGui
from PyQt5.QtCore import Qt, pyqtSignal
import random
from contextlib import suppress

def shiftRows(old, new, count):
    items = list(range(1, count + 1))
    item = items.pop(old - 1)
    items.insert(new - 1, item)
    return {item: i + 1 for i, item in enumerate(items)}
    
class Model(QtGui.QStandardItemModel):

    orderChanged = pyqtSignal()

    def __init__(self, rows, columns, parent = None):
        super().__init__(rows, columns, parent)
        self._moving = True
        for row in range(self.rowCount()):
            self.setData(self.index(row, 0), int(row + 1))
            self.setData(self.index(row, 1), random.randint(1000,9999))
            self.setData(self.index(row, 2), random.randint(1000,9999))
        self._moving = False

    def swapRows(self, old, new):
        self._moving = True
        d = shiftRows(old, new, self.rowCount())
        for row in range(self.rowCount()):
            index = self.index(row, 0)
            v = index.data()
            if d[v] != v:
                self.setData(index, d[v])
        self.orderChanged.emit()
        self._moving = False

    def flags(self, index):
        if index.column() == 0:
            return Qt.ItemIsEditable | Qt.ItemIsSelectable | Qt.ItemIsEnabled
        return Qt.ItemIsSelectable | Qt.ItemIsEnabled

    def headerData(self, section, orientation, role):
        if orientation == Qt.Vertical and role == Qt.DisplayRole:
            return self.index(section, 0).data()
        return super().headerData(section, orientation, role)

    def setData(self, index, value, role = Qt.DisplayRole):
        if role == Qt.EditRole and index.column() == 0:
            if self._moving:
                return super().setData(self, index, value, role)
            with suppress(ValueError):
                value = int(value)
                if value < 1 or value > self.rowCount():
                    return False
                prev = index.data()
                self.swapRows(prev, value)
            return True
        return super().setData(index, value, role)
    
if __name__ == "__main__":
    app = QtWidgets.QApplication([])

    model = Model(5, 3)
    sortModel = QtCore.QSortFilterProxyModel()
    sortModel.setSourceModel(model)
    model.orderChanged.connect(lambda: sortModel.sort(0))
    view = QtWidgets.QTableView()
    view.setModel(sortModel)
    view.show()

    app.exec_()
mugiseyebrows
  • 4,138
  • 1
  • 14
  • 15
  • Note that the OP is using PyQt, not PySide, so the import should reflect that and signals are named `pyqtSignal`. – musicamante Mar 23 '21 at 14:17
  • @mugiseyebrows thank you for your answer. I tried your suggestion, however, the numbers of the row headers get "shuffled". They should always stay the same from 1 to maximum (here: 5). Maybe needs some extra step to get them in order again?! – theozh Mar 23 '21 at 14:30
  • @theozh yes, overriding headerData can fix that issue, I updated code – mugiseyebrows Mar 23 '21 at 14:36
  • This approach seems to work right away. However, with my limited PyQt understanding it is pretty abstract and I fear to run into new unknowns when trying to integrate this minimal example into my larger code. But certainly, in the longer term I need to learn and understand these models. Thank you again! – theozh Mar 24 '21 at 05:28