Search code examples
pythonpyqtpyqt5undoqabstracttablemodel

How to undo a change in QAbstractTableModel?


I have this simple example: a value in last column of my QAbstractTableModel equals value in column 1 multiplied by 2. So every time a change is made to value in column 1 - it results to a change in column 2.

When the value in the last column has changed - a message box is shown asking whether user wishes confirm action.

Imagine user have changed value in column 1 and sees this message: I wish my app to undo changes if cancel is clicked (return both old values in column 1 and 2), can you help me with that? I need to define my 'go_back()' function:

class Mainwindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()

        self.table = QtWidgets.QTableView()
        self.setCentralWidget(self.table)

        self.data = [
            [1, 0.18, 0.36],
            [2, 0.25, 0.50],
            [3, 0.43, 0.86],
            ]

        self.model = MyModel(self.data)
        self.table.setModel(self.model)

        self.table.setSelectionBehavior(self.table.SelectRows)
        self.table.setSelectionMode(self.table.SingleSelection)

        self.model.dataChanged.connect(lambda index: self.count_last_column(index))
        self.model.dataChanged.connect(lambda index: self.if_last_column_changed(index))

    def calculations(self, position):
        value = self.model.list_data[position][1] * 2
        return value

    def count_last_column(self, index):
        if index.column() == 1:
            position = index.row()
            self.model.setData(self.model.index(position,2), self.calculations(position))

    def if_last_column_changed(self, index):
        if index.column() == 2:
            message_box, message_box_button = self.show_message_box()

            if message_box_button == 'Ok':
                pass
            elif message_box_button == 'Cancel':
                self.go_back()

    def show_message_box(self):
        self.message_box = QtWidgets.QMessageBox(QtWidgets.QMessageBox.Warning, 'Action', 'Value in column 3 has changed, confirm action?')

        self.message_box.Ok = self.message_box.addButton(QtWidgets.QMessageBox.Ok)
        self.message_box.Cancel = self.message_box.addButton(QtWidgets.QMessageBox.Cancel)

        self.message_box.exec()

        if self.message_box.clickedButton() == self.message_box.Ok:
            return (self.message_box, 'Ok')
        elif self.message_box.clickedButton() == self.message_box.Cancel:
            return (self.message_box, 'Cancel')

    def go_back(self):
        pass #################



class MyModel(QtCore.QAbstractTableModel):

    def __init__(self, list_data = [[]], parent = None):
        super(MyModel, self).__init__()
        self.list_data = list_data

    def rowCount(self, parent):
        return len(self.list_data)

    def columnCount(self, parent):
        return len(self.list_data[0])

    def data(self, index, role):

        if role == QtCore.Qt.DisplayRole:
            row = index.row()
            column = index.column()
            value = self.list_data[row][column]
            return value

        if role == QtCore.Qt.EditRole:
            row = index.row()
            column = index.column()
            value = self.list_data[row][column]
            return value


    def setData(self, index, value, role = QtCore.Qt.EditRole):

        if role == QtCore.Qt.EditRole:
            row = index.row()
            column = index.column()
            self.list_data[row][column] = value
            self.dataChanged.emit(index, index)
            return True

        return False

    def flags(self, index):
        return QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsUserCheckable


if __name__ == '__main__':

    app = QtWidgets.QApplication([])
    application = Mainwindow()
    application.show()


    sys.exit(app.exec())

Solution

  • One option is to use a QUndoStack with the item model, and push QUndoCommand objects onto the stack in setData. The benefit of this approach is that it makes it simple to implement more undo/redo controls going forward, if you want to.

    In MyModel, just create a stack in the constructor and add a line to push a command onto the stack right before you modify the list data (so the previous value can be stored in the command). The rest of the class is unchanged.

    class MyModel(QtCore.QAbstractTableModel):
    
        def __init__(self, list_data = [[]], parent = None):
            super(MyModel, self).__init__()
            self.list_data = list_data
            self.stack = QtWidgets.QUndoStack()
    
        def setData(self, index, value, role = QtCore.Qt.EditRole):
    
            if role == QtCore.Qt.EditRole:
                row = index.row()
                column = index.column()
                
                self.stack.push(CellEdit(index, value, self))
                
                self.list_data[row][column] = value
                self.dataChanged.emit(index, index)
                return True
    
            return False
    

    Create the QUndoCommand with the index, value, and model passed to the constructor so the desired cell can be modified with calls to undo or redo.

    class CellEdit(QtWidgets.QUndoCommand):
    
        def __init__(self, index, value, model, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.index = index
            self.value = value
            self.prev = model.list_data[index.row()][index.column()]
            self.model = model
    
        def undo(self):
            self.model.list_data[self.index.row()][self.index.column()] = self.prev
    
        def redo(self):
            self.model.list_data[self.index.row()][self.index.column()] = self.value
    

    And now all that needs to be done in go_back is to call the undo method twice, for both cells that were modified.

    def go_back(self):
        self.model.stack.undo()
        self.model.stack.undo()