Here is an MRE:
import sys, traceback, time
from PyQt5 import QtWidgets, QtCore, QtGui
class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle('Latency in GUI and too much paint()')
self.setGeometry(QtCore.QRect(100, 100, 1000, 800))
self.table_view = SegmentsTableView(self)
self.table_view.horizontalHeader().setVisible(False)
self.table_view.verticalHeader().setVisible(False)
self.setCentralWidget(self.table_view)
self.worker_thread = QtCore.QThread()
self.worker_thread.dead = False
self.generate_row_batches_task = GenerateRowBatchesTask()
self.generate_row_batches_task.moveToThread(self.worker_thread)
# connect signals and slots
self.worker_thread.started.connect(self.generate_row_batches_task.run)
self.generate_row_batches_task.stopped_signal.connect(self.worker_thread.quit)
self.generate_row_batches_task.stopped_signal.connect(self.generate_row_batches_task.deleteLater)
self.generate_row_batches_task.stopped_signal.connect(self.task_has_stopped)
self.worker_thread.finished.connect(self.worker_thread.deleteLater)
self.worker_thread.finished.connect(self.thread_has_finished)
self.generate_row_batches_task.batch_delivered_signal.connect(self.populate_table)
self.n_batch = 0
self.worker_thread.start()
def populate_table(self, batch):
# doesn't seem to help
# self.table_view.setUpdatesEnabled(False)
row_count = self.table_view.model().rowCount()
for n_row, row in enumerate(batch):
n_new_row = row_count + n_row
self.table_view.model().insertRow(n_new_row)
self.table_view.model().setItem(n_new_row, 0, QtGui.QStandardItem(row[0]))
self.table_view.model().setItem(n_new_row, 1, QtGui.QStandardItem(row[1]))
self.table_view.resizeRowToContents(n_new_row)
self.n_batch += 1
print(f'self.n_batch {self.n_batch}')
# doesn't seem to help
# self.table_view.setUpdatesEnabled(True)
if row_count == 0:
print('row count 0')
self.table_view.setColumnWidth(0, 400)
self.table_view.setColumnWidth(1, 400)
def task_has_stopped(self):
print('task has stopped')
def thread_has_finished(self):
print('thread has finished')
class GenerateRowBatchesTask(QtCore.QObject):
stopped_signal = QtCore.pyqtSignal()
batch_delivered_signal = QtCore.pyqtSignal(list)
def run(self):
rows = [
['one potatoe two potatoe', 'one potatoe two potatoe'],
['Sed ut perspiciatis, unde omnis iste natus error sit voluptatem accusantium doloremque',
'Sed ut <b>perspiciatis, unde omnis <i>iste natus</b> error sit voluptatem</i> accusantium doloremque'],
['Nemo enim ipsam voluptatem, quia voluptas sit, aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos, qui ratione voluptatem sequi nesciunt, neque porro quisquam est, qui do lorem ipsum, quia dolor sit amet consectetur adipiscing velit, sed quia non numquam do eius modi tempora incididunt, ut labore et dolore magnam aliquam quaerat voluptatem.',
'Nemo enim ipsam <i>voluptatem, quia voluptas sit, <b>aspernatur aut odit aut fugit, <u>sed quia</i> consequuntur</u> magni dolores eos</b>, qui ratione voluptatem sequi nesciunt, neque porro quisquam est, qui do lorem ipsum, quia dolor sit amet consectetur adipiscing velit, sed quia non numquam do eius modi tempora incididunt, ut labore et dolore magnam aliquam quaerat voluptatem.'
],
['Ut enim ad minima veniam',
'Ut enim ad minima veniam'],
['Quis autem vel eum iure reprehenderit',
'Quis autem vel eum iure reprehenderit'],
['At vero eos et accusamus et iusto odio dignissimos ducimus, qui blanditiis praesentium voluptatum deleniti atque corrupti, quos dolores et quas molestias excepturi sint, obcaecati cupiditate non provident, similique sunt in culpa, qui officia deserunt mollitia animi, id est laborum et dolorum fuga.',
'At vero eos et accusamus et iusto odio dignissimos ducimus, qui blanditiis praesentium voluptatum deleniti atque corrupti, quos dolores et quas molestias excepturi sint, obcaecati cupiditate non provident, similique sunt in culpa, qui officia deserunt mollitia animi, id est laborum et dolorum fuga.'
]]
print('run')
# this multiplies the list to give one with 192 rows
for i in range(3):
rows += rows
# now deliver 400 batches of these 192 rows
# NB these time.sleep()s have been chosen to produce just a bit of annoying latency in the GUI
for i in range(400):
batch = []
for n_row, row in enumerate(rows):
batch.append(row)
if n_row % 20 == 0:
time.sleep(0.000001)
self.batch_delivered_signal.emit(batch)
time.sleep(0.001)
self.stopped_signal.emit()
class SegmentsTableView(QtWidgets.QTableView):
def __init__(self, parent):
super().__init__(parent)
self.setItemDelegate(SegmentsTableViewDelegate(self))
self.setModel(QtGui.QStandardItemModel())
v_header = self.verticalHeader()
#
v_header.setMinimumSectionSize(5)
v_header.sectionHandleDoubleClicked.disconnect()
v_header.sectionHandleDoubleClicked.connect(self.resizeRowToContents)
self.horizontalHeader().sectionResized.connect(self.resizeRowsToContents)
def resizeRowToContents(self, row):
super().resizeRowToContents(row)
self.verticalHeader().resizeSection(row, self.sizeHintForRow(row))
def resizeRowsToContents(self):
header = self.verticalHeader()
for row in range(self.model().rowCount()):
hint = self.sizeHintForRow(row)
header.resizeSection(row, hint)
class SegmentsTableViewDelegate(QtWidgets.QStyledItemDelegate):
class EditorDocument(QtGui.QTextDocument):
def __init__(self, parent):
super().__init__(parent)
self.setDocumentMargin(0)
self.contentsChange.connect(self.contents_change)
self.height = None
parent.setDocument(self)
def contents_change(self, position, chars_removed, chars_added):
def resize_func():
if self.size().height() != self.height:
doc_size = self.size()
self.parent().resize(int(doc_size.width()), int(doc_size.height()))
QtCore.QTimer.singleShot(0, resize_func)
def __init__(self, *args):
super().__init__(*args)
self.pm_index_to_editor_dict = {}
self.paint_count = 0
def createEditor(self, parent, option, index):
class Editor(QtWidgets.QTextEdit):
def resizeEvent(self, event):
super().resizeEvent(event)
parent.parent().resizeRowToContents(index.row())
pm_index = QtCore.QPersistentModelIndex(index)
if pm_index in self.pm_index_to_editor_dict:
editor = self.pm_index_to_editor_dict[pm_index]
else:
editor = Editor(parent)
editor.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
editor.setFrameShape(0)
self.pm_index_to_editor_dict[pm_index] = editor
SegmentsTableViewDelegate.EditorDocument(editor)
return editor
def destroyEditor(self, editor, index):
super().destroyEditor(editor, index)
pm_index = QtCore.QPersistentModelIndex(index)
del self.pm_index_to_editor_dict[pm_index]
self.parent().resizeRowToContents(index.row())
def paint(self, painter, option, index):
self.paint_count += 1
if self.paint_count % 100 == 0:
# from this we see that it is always the visible rows (in the viewport) which are repainted
print(f'self.paint_count {self.paint_count} index.row() {index.row()}')
self.initStyleOption(option, index)
painter.save()
doc = QtGui.QTextDocument()
doc.setDocumentMargin(0)
doc.setDefaultFont(option.font)
doc.setTextWidth(option.rect.width())
doc.setHtml(option.text)
option.text = ""
option.widget.style().drawControl(QtWidgets.QStyle.CE_ItemViewItem, option, painter)
painter.translate(option.rect.left(), option.rect.top())
doc.drawContents(painter)
painter.restore()
def sizeHint(self, option, index):
self.initStyleOption(option, index)
pm_index = QtCore.QPersistentModelIndex(index)
if pm_index in self.pm_index_to_editor_dict:
doc = self.pm_index_to_editor_dict[pm_index].document()
else:
doc = QtGui.QTextDocument()
doc.setTextWidth(option.rect.width())
doc.setDefaultFont(option.font)
doc.setDocumentMargin(0)
doc.setHtml(option.text)
return QtCore.QSize(int(doc.idealWidth()), int(doc.size().height()))
app = QtWidgets.QApplication([])
app.setFont(default_font)
main_window = MainWindow()
main_window.show()
exec_return = app.exec()
sys.exit(exec_return)
What I'm doing in my real app is converting documents into rows of a table. The problem is with scaling up to very large documents consisting of maybe 50,000 words and therefore many 000s of table rows.
Delivering in batches from a worker thread using a signal seems to be the way to go. I find that the amount of time.sleep()
I then put into the worker thread has a direct relationship with the responsiveness of the GUI (best = very short but very often), and indeed omitting it completely seems to result in a crash, as though the GUI is being overwhelmed.
The problem is that, in my real application, I still find annoying latency when I'm trying to edit a cell while batches are still being delivered and added to the table. I think the culprit may be the item delegate's paint
method, which is being called many 000s of times.
paint
is happening even when I am not attempting to edit, and when the rows I can see in the viewport are completely unchanging... and yet the rows that are being painted are (as you might hope) the visible rows in the QTableView
's viewport.
This MRE illustrates this to some extent. I'm trying to find a way of reducing the amount of unnecessary painting going on. I'm not clear what triggers painting. In Java FX and Swing there is some mechanism of "invalidation" going on behind the scenes.
Qt models provide a couple of useful functions:
canFetchMore(parent)
, which says if the model can load more data (for a given parent);fetchMore(parent)
tells the model to do load more data (but the model decides the amount of the "more");Those functions are called by item views so that when they can request the model if there's more data to load whenever the user has scrolled to the end (usually, at the bottom) and eventually tell the model to do the fetching.
Considering the above, what you need to do is to implement a model that starts with a specified minimum amount of data, provides both fetching methods to load further data, and then add a timer to the view to request further fetching whenever it's possible, unless the current state()
is EditingState
or the model cannot fetch more data.
Since your code is too complex for an answer, I created a simpler example to explain the concept; the second columns shows when the index has been fetched starting from the moment the model has been created:
from PyQt5 import QtCore, QtWidgets
class TestModel(QtCore.QAbstractTableModel):
totalRowCount = 1980
currentRowCount = 25
fetchAmount = 25
def __init__(self):
super().__init__()
self.eTimer = QtCore.QElapsedTimer()
self.eTimer.start()
self.times = {
r:0 for r in range(self.currentRowCount)
}
def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
if orientation == QtCore.Qt.Horizontal and role == QtCore.Qt.DisplayRole:
return ('Item', 'Loading time')[section]
return super().headerData(section, orientation, role)
def rowCount(self, parent=None):
return self.currentRowCount
def columnCount(self, parent=None):
return 2
def canFetchMore(self, parent=None):
return self.currentRowCount < self.totalRowCount
def fetchMore(self, parent=None):
maxRow = min(self.totalRowCount, self.currentRowCount + self.fetchAmount)
self.beginInsertRows(QtCore.QModelIndex(), self.currentRowCount, maxRow - 1)
t = self.eTimer.elapsed() * .001
self.times.update({r:t for r in range(self.currentRowCount, maxRow)})
self.currentRowCount += self.fetchAmount
self.endInsertRows()
def data(self, index, role=QtCore.Qt.DisplayRole):
if role == QtCore.Qt.DisplayRole:
if index.column() == 0:
return 'Item at row {}'.format(index.row() + 1)
return self.times[index.row()]
def flags(self, index):
return super().flags(index) | QtCore.Qt.ItemIsEditable
class TestTable(QtWidgets.QTableView):
def __init__(self):
super().__init__()
self.resize(640, 480)
self._model = TestModel()
self.setModel(self._model)
self.horizontalHeader().setSectionResizeMode(
QtWidgets.QHeaderView.Stretch)
self.fetchTimer = QtCore.QTimer(
interval=1000, singleShot=True, timeout=self.fetchMore)
self.fetchTimer.start()
def fetchMore(self):
if self.model().canFetchMore():
if self.state() != self.EditingState:
self.model().fetchMore()
self.fetchTimer.start()
if __name__ == '__main__':
app = QtWidgets.QApplication([])
w = TestTable()
w.show()
app.exec_()
Note: the parent argument for canFetchMore
and fetchMore
is mandatory, just like rowCount
and columnCount
.
Obviously, if your model requires some time to fetch the actual data and finally insert new indexes (for instance, a remote database or network requests), you need to implement that with a further delay timer to "queue" the fetch requests.
You can create another single-shot timer in the model, and then push the fetching (beginInsertRows
and endInsertRows
) whenever the model is able to do that, even using a separate thread.
As a further and unrelated suggestion, please try to put more efforts in making your examples more minimal: your question is about general updating of a view with multiple items, all delegate aspects and resizing of items are completely unnecessary for that, and they just become an annoying and unnecessary distraction from what we should be focusing into.