theozh
theozh

Reputation: 25714

How to change row order in a QTableWidget by editing cells?

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:

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

Upvotes: 0

Views: 1807

Answers (2)

musicamante
musicamante

Reputation: 48231

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)

Upvotes: 2

mugiseyebrows
mugiseyebrows

Reputation: 4698

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_()

Upvotes: 1

Related Questions