Related
I am writing a gui application for processing multiple Excel files into a single file. The main window displays a table to which the user can add or remove files to be collected in the larger file. Each row will represent a file with columns for different parameters (data to be pulled from that file.) I've been working on implementing a QAbstractTableModel for this purpose, which works great, although I've not been able to update the table view. When a new row is added to my array of data, a new row is added to the table view but each column is empty. I'm not sure why this is as I've confirmed that the data array is updating as it should. Example:
class _tableModel(QAbstractTableModel):
def __init__(self, data=None):
QAbstractTableModel.__init__(self)
self.data = data
self.load_data(data)
def load_data(self, data):
self.input_files = data[:,0]
self.input_sheets = data[:,1]
self.column_count = 2
self.row_count = len(self.input_sheets)
def rowCount(self, parent=QModelIndex()):
return self.row_count
def columnCount(self, parent=QModelIndex()):
return self.column_count
def headerData(self, section, orientation, role):
if role != Qt.DisplayRole:
return None
if orientation == Qt.Horizontal:
return ("File", "Sheet")[section]
else:
return "{}".format(section)
def data(self, index, role=Qt.DisplayRole):
column = index.column()
row = index.row()
if role == Qt.DisplayRole:
if column == 0:
file = str(self.input_files[row])
return file
elif column == 1:
return str(self.input_sheets[row])
elif role == Qt.BackgroundRole:
return QColor(Qt.white)
elif role == Qt.TextAlignmentRole:
return Qt.AlignRight
return None
def appendRowData(self, data):
self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount())
self.data = np.concatenate((self.data, data), axis=0)
self.endInsertRows()
def setData(self, index, value, role=Qt.EditRole):
if role == Qt.EditRole:
self.data[index.row()][index.column()] = value
self.dataChanged.emit(index, index)
return True
class Widget(QWidget):
def __init__(self, data=None):
QWidget.__init__(self)
self.main_layout = QVBoxLayout()
self.addFileButton = QPushButton('Add File')
self.addFileButton.clicked.connect(self.addFileDialog)
self.main_layout.addWidget(self.addFileButton)
self.model = _tableModel(data)
self.table_view = QTableView()
self.table_view.setModel(self.model)
self.horizontal_header = self.table_view.horizontalHeader()
self.vertical_header = self.table_view.verticalHeader()
self.horizontal_header.setSectionResizeMode(
QHeaderView.ResizeToContents
)
self.vertical_header.setSectionResizeMode(
QHeaderView.ResizeToContents
)
self.horizontal_header.setStretchLastSection(True)
size = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
size.setHorizontalStretch(1)
self.table_view.setSizePolicy(size)
self.main_layout.addWidget(self.table_view)
self.setLayout(self.main_layout)
def addFileDialog(self):
self.fileWizard = QDialog()
self.wiz_layout = QFormLayout()
self.fileWizard.setLayout(self.wiz_layout)
self.selectExcelFile = QPushButton('Select Excel File')
self.selectExcelFile.clicked.connect(self.selectexcel)
self.wiz_layout.addRow(self.selectExcelFile)
self.selectedFileDisplay = QTextEdit()
self.wiz_layout.addRow(self.selectedFileDisplay)
self.sheet = QLineEdit()
self.wiz_layout.addRow('Sheet Name: ', self.sheet)
self.add_to_table = QPushButton('Add to File Table')
self.wiz_layout.addWidget(self.add_to_table)
self.add_to_table.clicked.connect(self._addTableEntry)
self.fileWizard.show()
def selectexcel(self):
self.filename = QFileDialog.getOpenFileName(self)
self.filename = self.filename[0]
self.selectedFileDisplay.setText(self.filename)
pass
def _addTableEntry(self):
row = self.model.rowCount()
data = np.array([[self.filename, self.sheet.text()]])
self.model.appendRowData(data)
class _mainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.generalLayout = QGridLayout()
data = np.array([['File name here','Sheet name here']])
self._centralWidget = Widget(data)
self.setCentralWidget(self._centralWidget)
self._centralWidget.setLayout(self.generalLayout)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = _mainWindow()
window.show()
sys.exit(app.exec_())
Both rowCount and columnCount must be dynamic, since they return the current extent of the model.
You're setting those values as static, so, while the model "accepts" the insertion of rows (as shown in the view), it's not able to access them because the row and column count don't reflect the updated model size.
def rowCount(self, parent=QModelIndex()):
return len(self.data[:,1])
Note that you're also not updating the self.data but in fact replacing it. Since you're using self.input_files for data(), it will probably result in an exception, as those arrays are not actually updated.
So, you either always use self.data anywhere with the correct slices, or use python properties to access input_files and input_sheets.
class _tableModel(QAbstractTableModel):
# ...
#property
def input_files(self):
return self.data[:,0]
# ...
There are different ways to change row order in QTableWidget:
by internal move via drag & drop
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:
the first column holds current position number
by editing these numbers I want to assign the new position to this row
I want to allow editing only on the first column
if an invalid position number is entered (within the range of number of rows) nothing should change
if a valid position number is entered the other position numbers in the first column are modified accordingly.
then I can get the rearranged rows in new order by clicking on the column header for sorting by the first column.
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:
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)
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_()
I am developing an application using PyQt5 (5.7.1) with Python 3.5. I use a QTableView to display a long list of record (more than 10,000). I want to be able to sort and filter this list on several columns at the same time.
I tried using a QAbstractTableModel with a QSortFilterProxyModel, reimplementing QSortFilterProxyModel.filterAcceptsRow() to have multicolumn filtering (see this blog post: http://www.dayofthenewdan.com/2013/02/09/Qt_QSortFilterProxyModel.html). but as this method is called for every row, filtering is very slow when there are a large number of rows.
I thought using Pandas for filtering could improve performance. So I created the following PandasTableModel class, which can indeed perform multicolumn filtering very quickly even with a large number of rows, as well as sorting:
import pandas as pd
from PyQt5 import QtCore, QtWidgets
class PandasTableModel(QtCore.QAbstractTableModel):
def __init__(self, parent=None, *args):
super(PandasTableModel, self).__init__(parent, *args)
self._filters = {}
self._sortBy = []
self._sortDirection = []
self._dfSource = pd.DataFrame()
self._dfDisplay = pd.DataFrame()
def rowCount(self, parent=QtCore.QModelIndex()):
if parent.isValid():
return 0
return self._dfDisplay.shape[0]
def columnCount(self, parent=QtCore.QModelIndex()):
if parent.isValid():
return 0
return self._dfDisplay.shape[1]
def data(self, index, role):
if index.isValid() and role == QtCore.Qt.DisplayRole:
return QtCore.QVariant(self._dfDisplay.values[index.row()][index.column()])
return QtCore.QVariant()
def headerData(self, col, orientation=QtCore.Qt.Horizontal, role=QtCore.Qt.DisplayRole):
if orientation == QtCore.Qt.Horizontal and role == QtCore.Qt.DisplayRole:
return QtCore.QVariant(str(self._dfDisplay.columns[col]))
return QtCore.QVariant()
def setupModel(self, header, data):
self._dfSource = pd.DataFrame(data, columns=header)
self._sortBy = []
self._sortDirection = []
self.setFilters({})
def setFilters(self, filters):
self.modelAboutToBeReset.emit()
self._filters = filters
self.updateDisplay()
self.modelReset.emit()
def sort(self, col, order=QtCore.Qt.AscendingOrder):
#self.layoutAboutToBeChanged.emit()
column = self._dfDisplay.columns[col]
ascending = (order == QtCore.Qt.AscendingOrder)
if column in self._sortBy:
i = self._sortBy.index(column)
self._sortBy.pop(i)
self._sortDirection.pop(i)
self._sortBy.insert(0, column)
self._sortDirection.insert(0, ascending)
self.updateDisplay()
#self.layoutChanged.emit()
self.dataChanged.emit(QtCore.QModelIndex(), QtCore.QModelIndex())
def updateDisplay(self):
dfDisplay = self._dfSource.copy()
# Filtering
cond = pd.Series(True, index = dfDisplay.index)
for column, value in self._filters.items():
cond = cond & \
(dfDisplay[column].str.lower().str.find(str(value).lower()) >= 0)
dfDisplay = dfDisplay[cond]
# Sorting
if len(self._sortBy) != 0:
dfDisplay.sort_values(by=self._sortBy,
ascending=self._sortDirection,
inplace=True)
# Updating
self._dfDisplay = dfDisplay
This class replicates the behaviour of a QSortFilterProxyModel, except for one aspect. If an item in the table is selected in the QTableView, sorting the table will not affect the selection (e.g. if the first row is selected before sorting, the first row will still be selected after sorting, not the same one as before.
I think the problem is related to the signals which are emitted. For filtering, I used modelAboutToBeReset() and modelReset(), but these signals cancel selection in the QTableView, so they are not suited for sorting. I read there (How to update QAbstractTableModel and QTableView after sorting the data source?) that layoutAboutToBeChanged() and layoutChanged() should be emitted. However, QTableView doesn't update if I use these signals (I don't understand why actually). When emitting dataChanged() once sorting is completed, QTableView is updated, but with the behaviour described above (selection not updated).
You can test this model using the following example :
class Ui_TableFilteringDialog(object):
def setupUi(self, TableFilteringDialog):
TableFilteringDialog.setObjectName("TableFilteringDialog")
TableFilteringDialog.resize(400, 300)
self.verticalLayout = QtWidgets.QVBoxLayout(TableFilteringDialog)
self.verticalLayout.setObjectName("verticalLayout")
self.tableView = QtWidgets.QTableView(TableFilteringDialog)
self.tableView.setObjectName("tableView")
self.tableView.setSortingEnabled(True)
self.tableView.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
self.verticalLayout.addWidget(self.tableView)
self.groupBox = QtWidgets.QGroupBox(TableFilteringDialog)
self.groupBox.setObjectName("groupBox")
self.verticalLayout_2 = QtWidgets.QVBoxLayout(self.groupBox)
self.verticalLayout_2.setObjectName("verticalLayout_2")
self.formLayout = QtWidgets.QFormLayout()
self.formLayout.setObjectName("formLayout")
self.column1Label = QtWidgets.QLabel(self.groupBox)
self.column1Label.setObjectName("column1Label")
self.formLayout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.column1Label)
self.column1Field = QtWidgets.QLineEdit(self.groupBox)
self.column1Field.setObjectName("column1Field")
self.formLayout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.column1Field)
self.column2Label = QtWidgets.QLabel(self.groupBox)
self.column2Label.setObjectName("column2Label")
self.formLayout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.column2Label)
self.column2Field = QtWidgets.QLineEdit(self.groupBox)
self.column2Field.setObjectName("column2Field")
self.formLayout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.column2Field)
self.verticalLayout_2.addLayout(self.formLayout)
self.verticalLayout.addWidget(self.groupBox)
self.retranslateUi(TableFilteringDialog)
QtCore.QMetaObject.connectSlotsByName(TableFilteringDialog)
def retranslateUi(self, TableFilteringDialog):
_translate = QtCore.QCoreApplication.translate
TableFilteringDialog.setWindowTitle(_translate("TableFilteringDialog", "Dialog"))
self.groupBox.setTitle(_translate("TableFilteringDialog", "Filters"))
self.column1Label.setText(_translate("TableFilteringDialog", "Name"))
self.column2Label.setText(_translate("TableFilteringDialog", "Occupation"))
class TableFilteringDialog(QtWidgets.QDialog):
def __init__(self, parent=None):
super(TableFilteringDialog, self).__init__(parent)
self.ui = Ui_TableFilteringDialog()
self.ui.setupUi(self)
self.tableModel = PandasTableModel()
header = ['Name', 'Occupation']
data = [
['Abe', 'President'],
['Angela', 'Chancelor'],
['Donald', 'President'],
['François', 'President'],
['Jinping', 'President'],
['Justin', 'Prime minister'],
['Theresa', 'Prime minister'],
['Vladimir', 'President'],
['Donald', 'Duck']
]
self.tableModel.setupModel(header, data)
self.ui.tableView.setModel(self.tableModel)
self.ui.column1Field.textEdited.connect(self.filtersEdited)
self.ui.column2Field.textEdited.connect(self.filtersEdited)
def filtersEdited(self):
filters = {}
values = [
self.ui.column1Field.text().lower(),
self.ui.column2Field.text().lower()
]
for col, value in enumerate(values):
if value == '':
continue
column = self.tableModel.headerData(col, QtCore.Qt.Horizontal, QtCore.Qt.DisplayRole).value()
filters[column]=value
self.tableModel.setFilters(filters)
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
dialog = TableFilteringDialog()
dialog.show()
sys.exit(app.exec_())
How can I make the selection follow the selected element when sorting ?
Thanks to ekhumoro, I found a solution. The sort function should store the persistent indexes, create new indexes and change them. Here is the code to do so. It seems sorting a bit slower with a lot of records, but this is acceptable.
def sort(self, col, order=QtCore.Qt.AscendingOrder):
# Storing persistent indexes
self.layoutAboutToBeChanged.emit()
oldIndexList = self.persistentIndexList()
oldIds = self._dfDisplay.index.copy()
# Sorting data
column = self._dfDisplay.columns[col]
ascending = (order == QtCore.Qt.AscendingOrder)
if column in self._sortBy:
i = self._sortBy.index(column)
self._sortBy.pop(i)
self._sortDirection.pop(i)
self._sortBy.insert(0, column)
self._sortDirection.insert(0, ascending)
self.updateDisplay()
# Updating persistent indexes
newIds = self._dfDisplay.index
newIndexList = []
for index in oldIndexList:
id = oldIds[index.row()]
newRow = newIds.get_loc(id)
newIndexList.append(self.index(newRow, index.column(), index.parent()))
self.changePersistentIndexList(oldIndexList, newIndexList)
self.layoutChanged.emit()
self.dataChanged.emit(QtCore.QModelIndex(), QtCore.QModelIndex())
edit: for an unknown reason, emitting dataChanged at the end speeds up sorting considerably. I tried to send a LayoutChangedHint with layoutAboutToBeChanged and layoutChanged (e.g. self.layoutChanged.emit([], QtCore.QAbstractItemModel.VerticalSortHing) ), but I get an error that these signals don't take arguments, which is strange considering the signature of these signals described in Qt5's doc.
Anyways, this code gives me the expected result, so that's already that. Understanding why it works is only a bonus ! ^^ If anyone has an explanation, I'd be interested to know though.
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.
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:
Method trying to load more items from the model, if the model has something not loaded yet
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.
a self-using model class, based on Dmitry's answer.
class EzQListModel(QAbstractListModel):
items_changed = Signal()
an_item_changed = Signal(int)
def __init__(self, batch_size=100, items_header='Items', parent=None):
super().__init__(parent)
self._batch_size = batch_size
self._current_size = 0
self._items = []
self.items_header = items_header
self.data_getter_mapping = {Qt.DisplayRole: self.get_display_data, Qt.BackgroundRole: self.get_background_data}
#property
def items_size(self):
return len(self._items)
def update_fetch_more(self):
if self.canFetchMore():
self.fetchMore()
return self
#contextlib.contextmanager
def ctx_change_items(self):
yield
self.items_changed.emit()
#contextlib.contextmanager
def ctx_change_an_item(self, index):
yield
self.an_item_changed.emit(index)
def clear_items(self):
with self.ctx_change_items():
self._items.clear()
self._current_size = 0
return self
def append_item(self, x):
with self.ctx_change_items():
self._items.append(x)
return self
def insert_item(self, index, x):
with self.ctx_change_items():
self._items.insert(index, x)
return self
def extend_items(self, items):
with self.ctx_change_items():
self._items.extend(items)
return self
def get_item(self, index):
return self._items[index]
def set_item(self, index, value):
with self.ctx_change_items():
with self.ctx_change_an_item(index):
self._items[index] = value
return self
def flags(self, index):
if not index.isValid():
return Qt.ItemIsEnabled
return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable
def rowCount(self, parent=QModelIndex()):
if parent.isValid():
return 0
n = self._current_size
if n <= self.items_size:
return n
else:
self._current_size = self.items_size
return self.items_size
#staticmethod
def get_none_data(index):
return None
def get_display_data(self, index: QModelIndex):
return self._items[index.row()]
#staticmethod
def get_background_data(index: QModelIndex):
palette = QApplication.palette()
return palette.alternateBase() if index.row() % 2 else palette.base()
def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return None
if self.items_size <= index.row() < 0:
return None
return self.data_getter_mapping.get(role, self.get_none_data)(index)
def setData(self, index, value, role=Qt.EditRole):
if not index.isValid():
return False
if role != Qt.EditRole:
return False
row = index.row()
if self.items_size <= row < 0:
return False
self._items[row] = value
self.dataChanged.emit(index, index)
# print(self.setData.__name__, row, self._items[row], self.data(index))
return True
def headerData(self, section, orientation, role=None):
if orientation != Qt.Horizontal:
return None
if section != 0:
return None
if role != Qt.DisplayRole:
return None
return self.items_header
def canFetchMore(self, parent: QModelIndex = QModelIndex()):
if parent.isValid():
return False
return self._current_size < self.items_size
def fetchMore(self, parent: QModelIndex = QModelIndex()):
if parent.isValid():
return
fcls = FirstCountLastStop().set_first_and_total(self._current_size,
min(self.items_size - self._current_size, self._batch_size))
self.beginInsertRows(parent, fcls.first, fcls.last)
self.endInsertRows()
self._current_size += fcls.total
class FirstCountLastStop:
def __init__(self):
self.first = 0
self.total = 0
self.last = 0
self.stop = 1
def set_first_and_total(self, first, count):
self.first = first
self.total = count
self.stop = first + count
self.last = self.stop - 1
return self
I want to produce a simple enough application which uses a QTreeView widget to show hierarchical data from a SQLite3 (flat) table, use QDataWidgetMapper to populate some lineedit fields, allow user to edit, which in turn updates the table. Simple & basic (for most!).
I have been working on the basis that the following process would be the best way of doing this:
Connect to Dbase
Query data
Create and populate custom QAbstractItemModel from the data (manipulating it through a dict to create nodes, parents and children dynamically - for each dict entry a 'node' is generated with an associated parent)
Use QDatawidgetmapper to populate other widgets
User edits data
QAbstractItemModel (QAIM) is updated
Then have to run an UPDATE, INSERT or whatever query using new values in the QAIM model.
Refresh the QAIM and associated widgets.
I realise if I were just using a QTableView or QListView I would not need the custom model and could just write straight back into the database. The process I have outlined above seems to mean having to keep two sets of data going - i.e. the SQLite table and the custom QAIM and ensure that they are both kept up to date. This seems a bit cumbersome to me and I'm sure there must be a better way of doing it where the QTreeView is taking its data straight from the SQLite table - with the obvious need for some manipulation to convert the flat data into hierarchical data.
I am wondering, of course, whether I have completely misunderstood the relationship between QAbstractItemModel and the QSQL*Models and I am overcomplicating it through ignorance?
Thanks
What you want is a proxy model that acts as a bridge between QSql*Model and the view. For that, you need to subclass QAbstractProxyModel. You have to have a consistent way of finding parent-child relationships in proxy model and mapping them to the source model, so that might require keeping some tally in the proxy model.
When you are sub-classing QAbstractProxyModel, you need to re-define, at minimum, these methods:
rowCount
columnCount
parent
index
data
mapToSource
mapFromSource
Also, keep in mind that QAbstractProxyModel does not auto-propagate signals through. So, in order to have the view be aware of changes in source model (like insert, delete, update), you need to pass them in the proxy model (while of course, updating your mappings in the proxy model).
It will require some work, but in the end you'll have a more flexible structure. And it will eliminate all the stuff that you need to do for synchronizing database and custom QAbstractItemModel.
Edit
A custom proxy model that groups items from a flat model according to a given column:
import sys
from collections import namedtuple
import random
from PyQt4 import QtCore, QtGui
groupItem = namedtuple("groupItem",["name","children","index"])
rowItem = namedtuple("rowItem",["groupIndex","random"])
class GrouperProxyModel(QtGui.QAbstractProxyModel):
def __init__(self, parent=None):
super(GrouperProxyModel, self).__init__(parent)
self._rootItem = QtCore.QModelIndex()
self._groups = [] # list of groupItems
self._groupMap = {} # map of group names to group indexes
self._groupIndexes = [] # list of groupIndexes for locating group row
self._sourceRows = [] # map of source rows to group index
self._groupColumn = 0 # grouping column.
def setSourceModel(self, source, groupColumn=0):
super(GrouperProxyModel, self).setSourceModel(source)
# connect signals
self.sourceModel().columnsAboutToBeInserted.connect(self.columnsAboutToBeInserted.emit)
self.sourceModel().columnsInserted.connect(self.columnsInserted.emit)
self.sourceModel().columnsAboutToBeRemoved.connect(self.columnsAboutToBeRemoved.emit)
self.sourceModel().columnsRemoved.connect(self.columnsRemoved.emit)
self.sourceModel().rowsInserted.connect(self._rowsInserted)
self.sourceModel().rowsRemoved.connect(self._rowsRemoved)
self.sourceModel().dataChanged.connect(self._dataChanged)
# set grouping
self.groupBy(groupColumn)
def rowCount(self, parent):
if parent == self._rootItem:
# root level
return len(self._groups)
elif parent.internalPointer() == self._rootItem:
# children level
return len(self._groups[parent.row()].children)
else:
return 0
def columnCount(self, parent):
if self.sourceModel():
return self.sourceModel().columnCount(QtCore.QModelIndex())
else:
return 0
def index(self, row, column, parent):
if parent == self._rootItem:
# this is a group
return self.createIndex(row,column,self._rootItem)
elif parent.internalPointer() == self._rootItem:
return self.createIndex(row,column,self._groups[parent.row()].index)
else:
return QtCore.QModelIndex()
def parent(self, index):
parent = index.internalPointer()
if parent == self._rootItem:
return self._rootItem
else:
parentRow = self._getGroupRow(parent)
return self.createIndex(parentRow,0,self._rootItem)
def data(self, index, role):
if role == QtCore.Qt.DisplayRole:
parent = index.internalPointer()
if parent == self._rootItem:
return self._groups[index.row()].name
else:
parentRow = self._getGroupRow(parent)
sourceRow = self._sourceRows.index(self._groups[parentRow].children[index.row()])
sourceIndex = self.createIndex(sourceRow, index.column(), 0)
return self.sourceModel().data(sourceIndex, role)
return None
def flags(self, index):
return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
def headerData(self, section, orientation, role):
return self.sourceModel().headerData(section, orientation, role)
def mapToSource(self, index):
if not index.isValid():
return QtCore.QModelIndex()
parent = index.internalPointer()
if not parent.isValid():
return QtCore.QModelIndex()
elif parent == self._rootItem:
return QtCore.QModelIndex()
else:
rowItem_ = self._groups[parent.row()].children[index.row()]
sourceRow = self._sourceRows.index(rowItem_)
return self.createIndex(sourceRow, index.column(), QtCore.QModelIndex())
def mapFromSource(self, index):
rowItem_ = self._sourceRows[index.row()]
groupRow = self._getGroupRow(rowItem_.groupIndex)
itemRow = self._groups[groupRow].children.index(rowItem_)
return self.createIndex(itemRow,index.column(),self._groupIndexes[groupRow])
def _clearGroups(self):
self._groupMap = {}
self._groups = []
self._sourceRows = []
def groupBy(self,column=0):
self.beginResetModel()
self._clearGroups()
self._groupColumn = column
sourceModel = self.sourceModel()
for row in range(sourceModel.rowCount(QtCore.QModelIndex())):
groupName = sourceModel.data(self.createIndex(row,column,0),
QtCore.Qt.DisplayRole)
groupIndex = self._getGroupIndex(groupName)
rowItem_ = rowItem(groupIndex,random.random())
self._groups[groupIndex.row()].children.append(rowItem_)
self._sourceRows.append(rowItem_)
self.endResetModel()
def _getGroupIndex(self, groupName):
""" return the index for a group denoted with name.
if there is no group with given name, create and then return"""
if groupName in self._groupMap:
return self._groupMap[groupName]
else:
groupRow = len(self._groupMap)
groupIndex = self.createIndex(groupRow,0,self._rootItem)
self._groupMap[groupName] = groupIndex
self._groups.append(groupItem(groupName,[],groupIndex))
self._groupIndexes.append(groupIndex)
self.layoutChanged.emit()
return groupIndex
def _getGroupRow(self, groupIndex):
for i,x in enumerate(self._groupIndexes):
if id(groupIndex)==id(x):
return i
return 0
def _rowsInserted(self, parent, start, end):
for row in range(start, end+1):
groupName = self.sourceModel().data(self.createIndex(row,self._groupColumn,0),
QtCore.Qt.DisplayRole)
groupIndex = self._getGroupIndex(groupName)
self._getGroupRow(groupIndex)
groupItem_ = self._groups[self._getGroupRow(groupIndex)]
rowItem_ = rowItem(groupIndex,random.random())
groupItem_.children.append(rowItem_)
self._sourceRows.insert(row, rowItem_)
self.layoutChanged.emit()
def _rowsRemoved(self, parent, start, end):
for row in range(start, end+1):
rowItem_ = self._sourceRows[start]
groupIndex = rowItem_.groupIndex
groupItem_ = self._groups[self._getGroupRow(groupIndex)]
childrenRow = groupItem_.children.index(rowItem_)
groupItem_.children.pop(childrenRow)
self._sourceRows.pop(start)
if not len(groupItem_.children):
# remove the group
groupRow = self._getGroupRow(groupIndex)
groupName = self._groups[groupRow].name
self._groups.pop(groupRow)
self._groupIndexes.pop(groupRow)
del self._groupMap[groupName]
self.layoutChanged.emit()
def _dataChanged(self, topLeft, bottomRight):
topRow = topLeft.row()
bottomRow = bottomRight.row()
sourceModel = self.sourceModel()
# loop through all the changed data
for row in range(topRow,bottomRow+1):
oldGroupIndex = self._sourceRows[row].groupIndex
oldGroupItem = self._groups[self._getGroupRow(oldGroupIndex)]
newGroupName = sourceModel.data(self.createIndex(row,self._groupColumn,0),QtCore.Qt.DisplayRole)
if newGroupName != oldGroupItem.name:
# move to new group...
newGroupIndex = self._getGroupIndex(newGroupName)
newGroupItem = self._groups[self._getGroupRow(newGroupIndex)]
rowItem_ = self._sourceRows[row]
newGroupItem.children.append(rowItem_)
# delete from old group
oldGroupItem.children.remove(rowItem_)
if not len(oldGroupItem.children):
# remove the group
groupRow = self._getGroupRow(oldGroupItem.index)
groupName = oldGroupItem.name
self._groups.pop(groupRow)
self._groupIndexes.pop(groupRow)
del self._groupMap[groupName]
self.layoutChanged.emit()