Erotemic Erotemic - 21 days ago 8
Python Question

PyQt4 force view to fetchMore from QAbstractItemModel

I have a QTableView that dynamically loads data from a custom model that inherits QAbstractItemModel. The model implements both fetchMore and canFetchMore.

The problem is that I would like to be able to select all rows for small datasets, but if I hit ctrl-a in the view it only will select the rows that are currently loaded.

Is there some mechanism to force the QTableView to fetch more rows? Ideally I would like to show a progress bar indicating the fraction of data that has been loaded from the model. Every few seconds I would like to force the model to load a bit more of the data, but I still want to let the user interact with the data that has been loaded so far. This way when the progress bar is complete the user can press ctrl-a and be confident that all data is selected.




Edit: I have another motivating use case. I want to jump to a specific row, but if that row is not loaded my interface does nothing.

How can I force a QAbstractItemModel to fetch more (or up to a specific row) and then force the QTableView to show it?

If I don't implement fetchMore and canFetchMore, the previous functionality works, but loading the tables is very slow. When I implement those methods the opposite happens. Not having an answer to this problem is causing issues with the usability of my qt interface, so I'm opening a bounty for this question.

Here is a method I'm using to select a specific row.

def select_row_from_id(view, _id, scroll=False, collapse=True):
"""
_id is from the iders function (i.e. an ibeis rowid)
selects the row in that view if it exists
"""
with ut.Timer('[api_item_view] select_row_from_id(id=%r, scroll=%r, collapse=%r)' %
(_id, scroll, collapse)):
qtindex, row = view.get_row_and_qtindex_from_id(_id)
if row is not None:
if isinstance(view, QtWidgets.QTreeView):
if collapse:
view.collapseAll()
select_model = view.selectionModel()
select_flag = QtCore.QItemSelectionModel.ClearAndSelect
#select_flag = QtCore.QItemSelectionModel.Select
#select_flag = QtCore.QItemSelectionModel.NoUpdate
with ut.Timer('[api_item_view] selecting name. qtindex=%r' % (qtindex,)):
select_model.select(qtindex, select_flag)
with ut.Timer('[api_item_view] expanding'):
view.setExpanded(qtindex, True)
else:
# For Table Views
view.selectRow(row)
# Scroll to selection
if scroll:
with ut.Timer('scrolling'):
view.scrollTo(qtindex)
return row
return None


If the user has manually scrolled past the row in question then this function works. However, if the user has not seen the specific row this function just scrolls back to the top of the view.

Answer

It's probably too late for the answer here but maybe it would still benefit someone in future.

Below one can find a working example of a list model with canFetchMore and fetchMore methods + a view with a couple of custom methods:

  1. Method trying to load more items from the model, if the model has something not loaded yet
  2. Method capable of fetching the specific rows from the model if they haven't been loaded yet

The QMainWindow subclass in the example has a timer which is used to repeatedly call the first of the above mentioned methods, each time forcing the load of another batch of items from the model into the view. The loading of items in batches over small time intervals allows one to avoid blocking the UI thread completely and be able to edit the items loaded so far with little to no lag. The example contains a progress bar showing the part of items loaded so far.

The QMainWindow subclass also has a spin box which allows one to pick a particular row to show in the view. If the corresponding item has already been fetched from the model, the view simply scrolls to it. Otherwise it fetches this row's item from the model first, in a synchronous i.e. UI blocking fashion.

Here's the full code of the solution, tested with python 3.5.2 and PyQt5:

import sys
from PyQt5 import QtWidgets, QtCore

class DelayedFetchingListModel(QtCore.QAbstractListModel):
    def __init__(self, batch_size=100, max_num_nodes=1000):
        QtCore.QAbstractListModel.__init__(self)
        self.batch_size = batch_size
        self.nodes = []
        for i in range(0, self.batch_size):
            self.nodes.append('node ' + str(i))
        self.max_num_nodes = max(self.batch_size, max_num_nodes)

    def flags(self, index):
        if not index.isValid():
            return QtCore.Qt.ItemIsEnabled
        return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEditable;

    def rowCount(self, index):
        if index.isValid():
            return 0
        return len(self.nodes)

    def data(self, index, role):
        if not index.isValid():
            return None
        if role != QtCore.Qt.DisplayRole:
            return None
        row = index.row()
        if row < 0 or row >= len(self.nodes):
            return None
        else:
            return self.nodes[row]

    def setData(self, index, value, role):
        if not index.isValid():
            return False
        if role != QtCore.Qt.EditRole:
            return False
        row = index.row()
        if row < 0 or row >= len(self.nodes):
            return False
        self.nodes[row] = value
        self.dataChanged.emit(index, index)
        return True

    def headerData(self, section, orientation, role):
        if section != QtCore.Qt.Horizontal:
            return None
        if section != 0:
            return None
        if role != QtCore.Qt.DisplayRole:
            return None
        return 'node'

    def canFetchMore(self, index):
        if index.isValid():
            return False
        return (len(self.nodes) < self.max_num_nodes)

    def fetchMore(self, index):
        if index.isValid():
            return
        current_len = len(self.nodes)
        target_len = min(current_len + self.batch_size, self.max_num_nodes)
        self.beginInsertRows(index, current_len, target_len - 1)
        for i in range(current_len, target_len):
            self.nodes.append('node ' + str(i))
        self.endInsertRows()

class ListView(QtWidgets.QListView):
    def __init__(self, parent=None):
        QtWidgets.QListView.__init__(self, parent)

    def jumpToRow(self, row):
        model = self.model()
        if model == None:
            return False
        num_rows = model.rowCount()
        while(row >= num_rows):
            res = fetchMoreRows(QtCore.QModelIndex())
            if res == False:
                return False
            num_rows = model.rowCount()
        index = model.index(row, 0, QtCore.QModelIndex())
        self.scrollTo(index, QtCore.QAbstractItemView.PositionAtCenter)
        return True

    def fetchMoreRows(self, index):
        model = self.model()
        if model == None:
            return False
        if not model.canFetchMore(index):
            return False
        model.fetchMore(index)
        return True

class MainForm(QtWidgets.QMainWindow):
    def __init__(self, parent=None):
        QtWidgets.QMainWindow.__init__(self, parent)
        # Setup the model
        self.max_num_nodes = 10000
        self.batch_size = 100
        self.model = DelayedFetchingListModel(batch_size=self.batch_size, max_num_nodes=self.max_num_nodes)
        # Setup the view
        self.view = ListView()
        self.view.setModel(self.model)
        # Update the currently selected row in the spinbox
        self.view.selectionModel().currentChanged.connect(self.onCurrentItemChanged)
        # Select the first row in the model
        index = self.model.index(0, 0, QtCore.QModelIndex())
        self.view.selectionModel().clearSelection()
        self.view.selectionModel().select(index, QtCore.QItemSelectionModel.Select)
        # Setup the spinbox
        self.spinBox = QtWidgets.QSpinBox()
        self.spinBox.setMinimum(0)
        self.spinBox.setMaximum(self.max_num_nodes-1)
        self.spinBox.setSingleStep(1)
        self.spinBox.valueChanged.connect(self.onSpinBoxNewValue)
        # Setup the progress bar showing the status of model data loading
        self.progressBar = QtWidgets.QProgressBar()
        self.progressBar.setRange(0, self.max_num_nodes)
        self.progressBar.setValue(0)
        self.progressBar.valueChanged.connect(self.onProgressBarValueChanged)
        # Add status bar but initially hidden, will only show it if there's something to say
        self.statusBar = QtWidgets.QStatusBar()
        self.statusBar.hide()
        # Collect all this stuff into a vertical layout
        self.layout = QtWidgets.QVBoxLayout()
        self.layout.addWidget(self.view)
        self.layout.addWidget(self.spinBox)
        self.layout.addWidget(self.progressBar)
        self.layout.addWidget(self.statusBar)
        self.window = QtWidgets.QWidget()
        self.window.setLayout(self.layout)
        self.setCentralWidget(self.window)
        # Setup timer to fetch more data from the model over small time intervals
        self.timer = QtCore.QBasicTimer()
        self.timerPeriod = 1000
        self.timer.start(self.timerPeriod, self)

    def onCurrentItemChanged(self, current, previous):
        if not current.isValid():
            return
        row = current.row()
        self.spinBox.setValue(row)

    def onSpinBoxNewValue(self, value):
        try:
            value_int = int(value)
        except ValueError:
            return
        num_rows = self.model.rowCount(QtCore.QModelIndex())
        if value_int >= num_rows:
            # There is no such row within the model yet, trying to fetch more
            while(True):
                res = self.view.fetchMoreRows(QtCore.QModelIndex())
                if res == False:
                    # We shouldn't really get here in this example since out
                    # spinbox's range is limited by exactly the number of items
                    # possible to fetch but generally it's a good idea to handle
                    # cases like this, when someone requests more rows than 
                    # the model has
                    self.statusBar.show()
                    self.statusBar.showMessage("Can't jump to row %d, the model has only %d rows" % (value_int, self.model.rowCount(QtCore.QModelIndex())))
                    return
                num_rows = self.model.rowCount(QtCore.QModelIndex())
                if value_int < num_rows:
                    break;
        if num_rows < self.max_num_nodes:
            # If there are still items to fetch more, check if we need to update the progress bar
            if self.progressBar.value() < value_int:
                self.progressBar.setValue(value_int)
        elif num_rows == self.max_num_nodes:
            # All items are loaded, nothing to fetch more -> no need for the progress bar
            self.progressBar.hide()
        # Update the selection accordingly with the new row and scroll to it
        index = self.model.index(value_int, 0, QtCore.QModelIndex())
        selectionModel = self.view.selectionModel()
        selectionModel.clearSelection()
        selectionModel.select(index, QtCore.QItemSelectionModel.Select)
        self.view.scrollTo(index, QtWidgets.QAbstractItemView.PositionAtCenter)
        # Ensure the status bar is hidden now
        self.statusBar.hide()

    def timerEvent(self, event):
        res = self.view.fetchMoreRows(QtCore.QModelIndex())
        if res == False:
            self.timer.stop()
        else:
            self.progressBar.setValue(self.model.rowCount(QtCore.QModelIndex()))
            if not self.timer.isActive():
                self.timer.start(self.timerPeriod, self)

    def onProgressBarValueChanged(self, value):
        if value >= self.max_num_nodes:
            self.progressBar.hide()

def main():
    app = QtWidgets.QApplication(sys.argv)
    form = MainForm()
    form.show()
    app.exec_()

if __name__ == '__main__':
    main()

One more thing I'd like to note is that this example expects the fetchMore method to do its work synchronously. But in more sophisticated approaches fetchMore doesn't actually have to act so. If your model loads its items from, say, a database then talking with the database synchronously in the UI thread would be a bad idea. Instead fetchMore implementation could start the asynchronous sequence of signal/slot communications with some object handling the communication with the database occurring in some background thread.

Comments