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]
# ...
Related
I would like to change the background color of specific index on my table, but only after a specific task is completed.
I know that I can use the Background role to change the color in my Table model, but I want to change the background color on external factors and not based on changes to the table itself. For example, the code below shows a basic example of a QTableView with 6 rows displayed in a QWidget. Inside the main app I am able to change the text of specific indexes using setData as seen below.
model.setData(model.index(2, 0), "Task Complete")
Here is the full code:
import sys
from PySide6.QtWidgets import (
QApplication, QWidget, QTableView, QVBoxLayout
)
from PySide6.QtCore import Qt, QAbstractTableModel
from PySide6.QtGui import QBrush
class TableModel(QAbstractTableModel):
def __init__(self, data):
super().__init__()
self._data = data
def data(self, index, role=Qt.DisplayRole):
# display data
if role == Qt.DisplayRole:
try:
return self._data[index.row()][index.column()]
except IndexError:
return ''
def setData(self, index, value, role=Qt.EditRole):
if role in (Qt.DisplayRole, Qt.EditRole):
# if value is blank
if not value:
return False
self._data[index.row()][index.column()] = value
self.dataChanged.emit(index, index)
return True
def rowCount(self, index):
return len(self._data)
def columnCount(self, index):
return len(self._data[0])
def flags(self, index):
return super().flags(index) | Qt.ItemIsEditable
class MainApp(QWidget):
def __init__(self):
super().__init__()
self.window_width, self.window_height = 200, 250
self.setMinimumSize(self.window_width, self.window_height)
self.layout = {}
self.layout['main'] = QVBoxLayout()
self.setLayout(self.layout['main'])
self.table = QTableView()
self.layout['main'].addWidget(self.table)
model = TableModel(data)
self.table.setModel(model)
# THIS IS WHERE THE QUESTION IS
model.setData(model.index(2, 0), "Task Complete") # Change background color instead of text
model.setData(model.index(5, 0), "Task Complete") # Change background color instead of text
if __name__ == '__main__':
data = [
["Task 1"],
["Task 2"],
["Task 3"],
["Task 4"],
["Task 5"],
["Task 6"],
]
app = QApplication(sys.argv)
myApp = MainApp()
myApp.show()
try:
sys.exit(app.exec())
except SystemExit:
print('Closing Window...')
I have tried to change the setData function to use the Qt.BackgroundRole instead of Qt.EditRole, but that does not work for changing the color. The result is that the code runs, but nothing happens.
I want to be able to fill the background with whatever color I choose based on the specific index I pick. However, I want this code to reside inside the MainApp class and not in the TableModel Class.
Suggestions Tried
Added code to data()
if role == Qt.BackgroundRole:
return QBrush(Qt.green)
Changed setData()
def setData(self, index, value, role=Qt.BackgroundRole):
if role in (Qt.DisplayRole, Qt.BackgroundRole):
# if value is blank
if not value:
return False
self._data[index.row()][index.column()] = value
self.dataChanged.emit(index, index)
return True
Changed setData in MainApp too
model.setData(model.index(5, 0), QBrush(Qt.green))
This resulted in highlighting the entire table in green instead of specific index.
If you want to set different colors for each index, you must store the color information in another data structure and return the corresponding value for the index.
Both data() and setData() must access different values depending on the role (see the documentation about item roles), meaning that you must not use self._data indiscriminately for anything role. If you set the color for a row/column in the same data structure you use for the text, then the text is lost.
A simple solution is to create a list of lists that has the same size of the source data, using None as default value.
class TableModel(QAbstractTableModel):
def __init__(self, data):
super().__init__()
self._data = data
rows = len(data)
cols = len(data[0])
self._backgrounds = [[None] * cols for _ in range(rows)]
def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return
elif role in (Qt.DisplayRole, Qt.EditRole):
return self._data[index.row()][index.column()]
elif role == Qt.BackgroundRole:
return self._backgrounds[index.row()][index.column()]
def setData(self, index, value, role=Qt.EditRole):
if (
not index.isValid()
or index.row() >= len(self._data)
or index.column() >= len(self._data[0])
):
return False
if role == Qt.EditRole:
self._data[index.row()][index.column()] = value
elif role == Qt.BackgroundRole:
self._backgrounds[index.row()][index.column()] = value
else:
return False
self.dataChanged.emit(index, index, [role])
return True
Note: you should always ensure that data has at least one row, otherwise columnCount() will raise an exception.
Then, to update the color, you must also use the proper role:
model.setData(model.index(5, 0), QBrush(Qt.green), Qt.BackgroundRole)
Note that if you don't need to keep the data structure intact (containing only the displayed values), a common solution is to use dictionaries.
You could use common dictionary that has the role as key and the data structure as value:
class TableModel(QAbstractTableModel):
def __init__(self, data):
super().__init__()
rows = len(data)
cols = len(data[0])
self._data = {
Qt.DisplayRole: data,
Qt.BackgroundRole: [[None] * cols for _ in range(rows)]
}
# implement the other functions accordingly
Otherwise, use a single structure that uses unique dictionaries for each item:
class TableModel(QAbstractTableModel):
def __init__(self, data):
super().__init__()
self._data = []
for rowData in data:
self._data.append([
{Qt.DisplayRole: item} for item in rowData
])
def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return
data = self._data[index.row()][index.column()]
if role == Qt.EditRole:
role = Qt.DisplayRole
return data.get(role)
def setData(self, index, value, role=Qt.EditRole):
if (
not index.isValid()
or role not in (Qt.EditRole, Qt.BackgroundRole)
):
return False
self._data[index.row()][index.column()][role] = value
self.dataChanged.emit(index, index, [role])
return True
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())
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()
I am currently trying to load a dataframe into a PyQt QTableView to allow the re-naming of desired columns. Once the re-naming is complete, save the new dataframe as a .csv in a local folder. I cannot get the updated QTableView model to save. The workflow can be seen below.
Read the .csv I would like to modify
Load the dataframe into the QTableView with a Combobox in every column for the first row
Be able to select different options to rename the column
Select the desired name for desired columns
It would also be helpful if a certain option was selected in the Combobox (for instance, "Default"), it would make the desired column name to be the same as the original column name.
Save the final dataframe as a file to a local folder
Note: Only columns that have a value in the combobox are kept in the final dataset. Columns that are specified as "Default" are kept with the original column name.
Example of the Code below:
form_class = uic.loadUiType("DataProcessing.ui")[0]
class MyWindowClass(QtWidgets.QMainWindow, form_class):
def __init__(self, parent=None):
super().__init__()
self.setupUi(self)
self.PushButtonDisplay.clicked.connect(self.IP_Data_Display)
self.PushButtonImport.clicked.connect(self.IP_File_Import)
def IP_Data_Display(self):
DT_Disp = self.CBdisplay.currentText()
data = pd.read_csv('Example.csv')
data.loc[-1] = pd.Series([np.nan])
data.index = data.index + 1
data = data.sort_index()
model = PandasModel(data)
self.TView.setModel(model)
for column in range(model.columnCount()):
c = QtWidgets.QComboBox()
c.addItems(['','Option 1','Option 2','Option 3','Option 4','Default'])
i = self.TView.model().index(0,column)
self.TView.setIndexWidget(i,c)
def IP_File_Import(self):
newModel = self.TView.model()
data = []
for row in range(newModel.rowCount()):
rowRes = []
for column in range(newModel.columnCount()):
index = newModel.index(row, column)
item = newModel.data(index)
if item != '':
rowRes.append(item)
data.append(rowRes)
dataFrame = pd.DataFrame(data)
dataFrame.to_csv('Test.csv')#, index=False, header=False)
class PandasModel(QtCore.QAbstractTableModel):
def __init__(self, df = pd.DataFrame(), parent=None):
QtCore.QAbstractTableModel.__init__(self, parent=parent)
self._df = df
def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
if role != QtCore.Qt.DisplayRole:
return QtCore.QVariant()
if orientation == QtCore.Qt.Horizontal:
try:
return self._df.columns.tolist()[section]
except (IndexError, ):
return QtCore.QVariant()
elif orientation == QtCore.Qt.Vertical:
try:
return self._df.index.tolist()[section]
except (IndexError, ):
return QtCore.QVariant()
def data(self, index, role=QtCore.Qt.DisplayRole):
if role != QtCore.Qt.DisplayRole:
return QtCore.QVariant()
if not index.isValid():
return QtCore.QVariant()
return QtCore.QVariant(str(self._df.iloc[index.row(), index.column()]))
def setData(self, index, value, role):
row = self._df.index[index.row()]
col = self._df.columns[index.column()]
if hasattr(value, 'toPyObject'):
value = value.toPyObject()
else:
dtype = self._df[col].dtype
if dtype != object:
value = None if value == '' else dtype.type(value)
self._df.set_value(row, col, value)
return True
def rowCount(self, parent=QtCore.QModelIndex()):
return len(self._df.index)
def columnCount(self, parent=QtCore.QModelIndex()):
return len(self._df.columns)
def sort(self, column, order):
colname = self._df.columns.tolist()[column]
self.layoutAboutToBeChanged.emit()
self._df.sort_values(colname, ascending= order == QtCore.Qt.AscendingOrder, inplace=True)
self._df.reset_index(inplace=True, drop=True)
self.layoutChanged.emit()
if __name__ == '__main__':
app = QtWidgets.QApplication.instance()
if app is None:
app = QtWidgets.QApplication(sys.argv)
else:
print('QApplication instance already exists: %s' % str(app))
main = MyWindowClass(None)
main.show()
sys.exit(app.exec_())
Instead of using setItemWidget it is best to create a delegate that has a permanently open editor since it has access to the QModelIndex, on the other hand a row that will have the data of the header is added. And finally use _df to get the pandas.
import sys
from PyQt5 import QtCore, QtWidgets, uic
import pandas as pd
import numpy as np
form_class = uic.loadUiType("DataProcessing.ui")[0]
class PandasModel(QtCore.QAbstractTableModel):
def __init__(self, df = pd.DataFrame(), parent=None):
QtCore.QAbstractTableModel.__init__(self, parent=parent)
self._df = df
def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole):
if role != QtCore.Qt.DisplayRole:
return QtCore.QVariant()
if orientation == QtCore.Qt.Horizontal:
try:
return self._df.columns.tolist()[section]
except (IndexError, ):
return QtCore.QVariant()
return super(PandasModel, self).headerData(section, orientation, role)
def data(self, index, role=QtCore.Qt.DisplayRole):
if role != QtCore.Qt.DisplayRole:
return QtCore.QVariant()
if not index.isValid():
return QtCore.QVariant()
if index.row() == 0:
return QtCore.QVariant(self._df.columns.values[index.column()])
return QtCore.QVariant(str(self._df.iloc[index.row()-1, index.column()]))
def setData(self, index, value, role):
if index.row() == 0:
if isinstance(value, QtCore.QVariant):
value = value.value()
if hasattr(value, 'toPyObject'):
value = value.toPyObject()
self._df.columns.values[index.column()] = value
self.headerDataChanged.emit(QtCore.Qt.Horizontal, index.column(), index.column())
else:
col = self._df.columns[index.column()]
row = self._df.index[index.row()-1]
if isinstance(value, QtCore.QVariant):
value = value.value()
if hasattr(value, 'toPyObject'):
value = value.toPyObject()
else:
dtype = self._df[col].dtype
if dtype != object:
value = None if value == '' else dtype.type(value)
self._df.set_value(row, col, value)
return True
def rowCount(self, parent=QtCore.QModelIndex()):
return len(self._df.index) +1
def columnCount(self, parent=QtCore.QModelIndex()):
return len(self._df.columns)
def sort(self, column, order):
colname = self._df.columns.tolist()[column]
self.layoutAboutToBeChanged.emit()
self._df.sort_values(colname, ascending= order == QtCore.Qt.AscendingOrder, inplace=True)
self._df.reset_index(inplace=True, drop=True)
self.layoutChanged.emit()
class ComboBoxDelegate(QtWidgets.QStyledItemDelegate):
def createEditor(self, parent, option, index):
editor = QtWidgets.QComboBox(parent)
value = index.data()
options = [value, 'Option 1','Option 2','Option 3','Option 4','Default']
editor.addItems(options)
editor.currentTextChanged.connect(self.commitAndCloseEditor)
return editor
#QtCore.pyqtSlot()
def commitAndCloseEditor(self):
editor = self.sender()
self.commitData.emit(editor)
class MyWindowClass(QtWidgets.QMainWindow, form_class):
def __init__(self, parent=None):
super().__init__()
self.setupUi(self)
self.PushButtonDisplay.clicked.connect(self.IP_Data_Display)
self.PushButtonImport.clicked.connect(self.IP_File_Import)
delegate = ComboBoxDelegate(self.TView)
self.TView.setItemDelegateForRow(0, delegate)
def IP_Data_Display(self):
DT_Disp = self.CBdisplay.currentText()
data = pd.read_csv('Example.csv')
data = data.sort_index()
model = PandasModel(data)
self.TView.setModel(model)
for i in range(model.columnCount()):
ix = model.index(0, i)
self.TView.openPersistentEditor(ix)
def IP_File_Import(self):
newModel = self.TView.model()
dataFrame = newModel._df.copy()
dataFrame.to_csv('Test.csv') #, index=False, header=False)
if __name__ == '__main__':
app = QtWidgets.QApplication.instance()
if app is None:
app = QtWidgets.QApplication(sys.argv)
else:
print('QApplication instance already exists: %s' % str(app))
main = MyWindowClass(None)
main.show()
sys.exit(app.exec_())
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 have a hierarchical data source for a QColumnView I want to fill. The data source loads the data from a server using a REST interface.
Lets say the hierarchy looks like this:
Car_Manufacturer -> Car_Type -> Specific_Model -> Motor_Type
I have to use a QColumnView to display this (since it is a customer requirement). The behavior is supposed to be like this:
When the program starts, it loads the Car_Manufacturer from the server. When one of the Car_Manufacturer items is clicked, the Car_Type items for the selected Car_Manufacturer is loaded from the server and displayed in a new column. When the Car_Manufacturer is clicked again, the data has to be fetched again from the server and the column has to be updated. When Car_Type is clicked, the Specific_Model items for this Car_Manufacturer and Car_type have to be queried from the server and loaded into a new column... and so on.
The datasource has this api:
datasource.get_manufacturers(hierarchy) # hierarchy = []
datasource.get_car_type(hierarchy) # hierarchy = [manufacturer, ]
datasource.get_specific_model(hierarchy) # hierarchy = [manufacturer, car_type]
datasource.get_motor_type(hierarchy) # hierarchy = [manufacturer, car_type, specific_model ]
Where each element in the hierarchy is a string key representation of the item. When an item is clicked it has to inform a controller about this with the hierarchy of the curernt item.
How can I get the QColumnView to update the children of one item when the item is clicked using the datasource? How can this stay flexible when a new hierarchy layer is added or removed?
Here is an example which implements a custom DirModel.
The method _create_children is called lazily and should return a list of instances which implement AbstractTreeItem.
import sys
import os
import abc
from PyQt4.QtCore import QAbstractItemModel, QModelIndex, Qt, QVariant
from PyQt4.QtGui import QColumnView, QApplication
class TreeModel(QAbstractItemModel):
def __init__(self, root, parent=None):
super(TreeModel, self).__init__(parent)
self._root_item = root
self._header = self._root_item.header()
def columnCount(self, parent=None):
if parent and parent.isValid():
return parent.internalPointer().column_count()
else:
return len(self._header)
def data(self, index, role):
if not index.isValid():
return QVariant()
item = index.internalPointer()
if role == Qt.DisplayRole:
return item.data(index.column())
if role == Qt.UserRole:
if item:
return item.person
return QVariant()
def headerData(self, column, orientation, role):
if orientation == Qt.Horizontal and role == Qt.DisplayRole:
try:
return QVariant(self._header[column])
except IndexError:
pass
return QVariant()
def index(self, row, column, parent):
if not self.hasIndex(row, column, parent):
return QModelIndex()
if not parent.isValid():
parent_item = self._root_item
else:
parent_item = parent.internalPointer()
child_item = parent_item.child_at(row)
if child_item:
return self.createIndex(row, column, child_item)
else:
return QModelIndex()
def parent(self, index):
if not index.isValid():
return QModelIndex()
child_item = index.internalPointer()
if not child_item:
return QModelIndex()
parent_item = child_item.parent()
if parent_item == self._root_item:
return QModelIndex()
return self.createIndex(parent_item.row(), 0, parent_item)
def rowCount(self, parent=QModelIndex()):
if parent.column() > 0:
return 0
if not parent.isValid():
parent_item = self._root_item
else:
parent_item = parent.internalPointer()
return parent_item.child_count()
class AbstractTreeItem(object):
__metaclass__ = abc.ABCMeta
def __init__(self, parent=None):
self._children = None
self._parent = parent
#abc.abstractmethod
def header(self):
#return ["name"]
raise NotImplementedError(self.header)
#abc.abstractmethod
def column_count(self):
#return 1
raise NotImplementedError(self.column_count)
def parent(self):
return self._parent
#abc.abstractmethod
def _create_children(self):
# subclass this method
return []
def row(self):
if self._parent:
return self._parent._children.index(self)
return 0
#property
def children(self):
if self._children is None:
self._children = self._create_children()
return self._children
def child_at(self, row):
return self.children[row]
#abc.abstractmethod
def data(self, column):
#return ""
raise NotImplementedError(self.data)
def child_count(self):
count = len(self.children)
return count
class DirPathModel(AbstractTreeItem):
def __init__(self, root="/", parent=None):
super(DirPathModel, self).__init__(parent)
self._root = root
def _create_children(self):
print "walking into", self._root
children = []
try:
entries = os.listdir(self._root)
except OSError:
# no permission etc
entries = []
for name in entries:
fn = os.path.join(self._root, name)
if os.path.isdir(fn):
children.append(self.__class__(fn, self))
return children
def data(self, column):
#assert column == 0
return os.path.basename(self._root)
def header(self):
return ["name"]
def column_count(self):
return 1
def main():
app = QApplication(sys.argv)
view = QColumnView()
view.setWindowTitle("Dynamic Column view test")
view.resize(1024, 768)
root = DirPathModel("/")
model = TreeModel(root)
view.setModel(model)
view.show()
return app.exec_()
if __name__ == "__main__":
sys.exit(main() or 0)
Asuming you can't bring all data at once and filter it out, you'll have to modify the item model (adding and removing rows) on the go based on whatever the user has selected from the QColumnView.
There's more than one way to remove the items:
You can use the index of the selected column and remove all items "on the left" of this column.
You can remove items based whose parent (or grandparent) matches the selection being made
Any option you take, you'll have to mirror the relationship between items in some way. That or implement from QAbstractItemModel, which I think would be an overkill.