pyqtqtableviewqabstractitemmodel

Update a QTableView entirely when data has changed


I have a QTableView using a custom QAbstractTableModel, and I would like to update the entire table view when the underlying data has change.

Note that a data change can be anything:

All existing solutions I found involve using insertRows / deleteRows, but AFAIK that would require me to do some bookkeeping of my data to find out exactly which existing rows changed, and which rows where added in between existing rows.

Is there a way to just tell the view that the entire model may have changed and that it should update everything?

Here is a code example to demonstrate my problem:

import sys
from PyQt5 import QtCore, QtWidgets
from PyQt5.QtCore import Qt


class MyTableModel(QtCore.QAbstractTableModel):
    def __init__(self):
        super().__init__()
        self._data = [
            ("spam", "spam", "spam", "spam"),
            ("spam", "spam", "spam", "spam"),
            ("spam", "spam", "spam", "spam"),
        ]

    def headerData(self, section: int, orientation: Qt.Orientation, role=Qt.DisplayRole):
        if role == Qt.DisplayRole and orientation == Qt.Horizontal:
            return f"Foo {section + 1}"

    def data(self, index, role=Qt.DisplayRole):
        if role == Qt.DisplayRole:
            return self._data[index.row()][index.column()]

    def rowCount(self, parent=QtCore.QModelIndex()):
        return len(self._data)

    def columnCount(self, parent=QtCore.QModelIndex()):
        return 4

    def refresh_data(self):
        self._data.insert(0, ("ham", "ham", "ham", "ham"))
        topLeft = self.createIndex(0, 0)
        bottomRight = self.createIndex(self.rowCount(), self.columnCount())
        self.dataChanged.emit(topLeft, bottomRight)


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

        widget = QtWidgets.QWidget()
        layout = QtWidgets.QVBoxLayout()
        widget.setLayout(layout)
        self.setCentralWidget(widget)

        self.table = QtWidgets.QTableView()
        layout.addWidget(self.table)

        self.model = MyTableModel()
        self.table.setModel(self.model)

        update_data_button = QtWidgets.QPushButton("Update data")
        update_data_button.clicked.connect(self.model.refresh_data)
        layout.addWidget(update_data_button)


app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()

When I click the update_data_button, I see how the new data is inserted at the beginning, but the number of rows in the view is not updated, so the last rows are just dropped.

Initial data

View after one click (note that the last row is dropped)

In the future I want to implement a more fine grained updating of my data, by inserting/deleting rows as needed, but I'm not there yet. I have to assume that my data source can change any cell anywhere in the data and add/remove rows anywhere, without being able to easily find where the change happened (short of having to keep a copy of the data and iterate through both the old and new data structures to find the differences).


Solution

  • The dataChanged signal is not appropriate for this purpose: it only tells any connected view that the data of the currently known items has changed. The item view will then assume that the model size and hierarchy is still the same and cannot realize that there are less or more rows or columns. In fact, just calling dataChanged when the model size has changed could potentially result in a raised exception.

    Basically speaking, it's just like notifying that a value in a list item has been changed: the assumption is that the list still has the same item count.

    A common way to work around this is to emit the layoutChanged signal, but that's not very appropriate: the concept of layout normally indicates that the basic structure (row and column count) remains unchanged. That signal should only be used when the actual layout has changed: for instance, when the model has been sorted.

    While Qt item views are normally able to "work around" this approach, it's not consistent nor reliable. Views have fail safe systems that automatically ignore some aspects when the model is not consistent with what they "previously" knew, but trusting them is just an educated guess at least, and, formally, an unreliable assumption.

    Whenever the model radically changes (row/column size and item data are invalidated), the more appropriate solution is to rely on the "model reset" concept.

    The basic solution is to just emit the modelReset signal, but since item views also consider persistent indexes and their selection model, a more correct way is to properly call the begin/end functions QAbstractItemModel provides. In this case, the order is:

    1. call beginResetModel();
    2. change the underlying data;
    3. call endResetModel()

    Using the functions above will also prepare you to further changes in the model: whenever rows or columns are added, removed or moved, you need to call the appropriate begin*() function, then apply the required changes, and finally call the related end*() function.

    This ensures that the connected views keep their status properly updated, considering index order, selections, and even using some important performance optimization that are necessary for large or complex models.