I'm trying to link a data from a QTableModel to a QComboBox via a mapper (QDataWidgetMapper) similarly to the Qt example showed here : https://doc.qt.io/qt-5/qtwidgets-itemviews-simplewidgetmapper-example.html
Using the propertyName "currentIndex" or "currentText", I know I can map directly the combobox. But I would like to map a user defined data.
Here is a small example : there is Object with Name and CategoryID, and Categories with ID and Name. The combobox has all the categories possible, but its index should change according to the object's CategoryID.
Here is what I have until now:
TableModel :
class ObjectModel(QAbstractTableModel):
def __init__(self, objects, parent=None):
super(ObjectModel, self).__init__(parent)
self.columns = ['name', 'category']
self.objectList = objects
def rowCount(self, parent=QModelIndex()):
return len(self.objectList)
def columnCount(self, parent=QModelIndex()):
return len(self.columns)
def headerData(self, section, orientation, role=Qt.DisplayRole):
if orientation == Qt.Horizontal and role == Qt.DisplayRole:
return self.columns[section].title()
def data(self, index, role=Qt.DisplayRole):
if role == Qt.DisplayRole or role == Qt.EditRole:
row = self.objectList[index.row()]
column_key = self.columns[index.column()]
return row[column_key]
else:
return None
def setData(self, index, value, role=Qt.EditRole):
if not index.isValid() or role != Qt.EditRole:
return False
if self.objectList:
column_key = self.columns[index.column()]
self.objectList[index.row()][column_key] = value
self.dataChanged.emit(index, index, [])
return True
return True
StandardItemModel :
class CategoryModel(QStandardItemModel):
def __init__(self, categories, parent=None):
super(CategoryModel, self).__init__(parent)
self.insertColumns(0, 2, QModelIndex())
self.insertRows(0, len(categories), QModelIndex())
self.categoryList = sorted(categories, key=lambda idx: (idx['name']))
for i, category in enumerate(categories):
self.setData(self.index(i, 0, QModelIndex()), category["id"], Qt.UserRole)
self.setData(self.index(i, 1, QModelIndex()), category["name"], Qt.UserRole)
def data(self, index, role=Qt.DisplayRole):
if role == Qt.DisplayRole or role == Qt.EditRole:
return self.categoryList[index.row()]['name']
if role == Qt.UserRole:
return self.categoryList[index.row()]["id"]
View :
class CustomView(QWidget):
def __init__(self, parent=None):
super(CustomView, self).__init__(parent)
self.mapper = QDataWidgetMapper()
self.spinboxMapperIndex = QSpinBox()
self.lineEdit = QLineEdit()
self.comboBox = QComboBox()
self.spinboxMapperIndex.valueChanged.connect(self.changeMapperIndex)
self.setupLayout()
def setModel(self, model):
self.mapper.setModel(model)
self.mapper.addMapping(self.lineEdit, 0)
self.mapper.addMapping(self.comboBox, 1, b'currentData')
self.mapper.toFirst()
def changeMapperIndex(self, index):
self.mapper.setCurrentIndex(index)
def setupLayout(self):
layout = QVBoxLayout()
layout.addWidget(self.spinboxMapperIndex)
layout.addWidget(self.lineEdit)
layout.addWidget(self.comboBox)
self.setLayout(layout)
Main :
if __name__ == '__main__':
app = QApplication(sys.argv)
categoryList = [
{
"id": 23,
"name": "Flower"
},
{
"id": 456,
"name": "Furniture"
}
]
categoryModel = CategoryModel(categoryList)
objectList = [
{
"name": "Lotus",
"category": 23
},
{
"name": "Table",
"category": 456
}
]
objectModel = ObjectModel(objectList)
view = CustomView()
view.comboBox.setModel(categoryModel)
view.setModel(objectModel)
view.show()
sys.exit(app.exec_())
If I change the ID to a logical increment integer, and I use the currentIndex as propertyName in the mapping process, this is working. But I would like a more generic way if I change the order in the category list.
Long story short: QDataWidgetMapper cannot operate by default the choice of the underlying data amid a list of alternatives. Furthermore, it uses Qt.EditRole by default for its implementation. Please, have a look to this answer and to this article for a deeper explanation of the problem and a better understand of the solution proposed
The following code should work as you need
from PySide2.QtCore import QAbstractTableModel, QModelIndex, Qt, QObject, QAbstractItemModel
from PySide2.QtGui import QStandardItemModel, QStandardItem
from PySide2.QtWidgets import QWidget, QApplication, QDataWidgetMapper, QSpinBox, QComboBox, QLineEdit, QVBoxLayout, \
QItemDelegate
import sys
class ComboUserDataDelegate(QItemDelegate):
def __init__(self, parent: QObject = None):
QItemDelegate.__init__(self, parent=parent)
def setEditorData(self, editor: QWidget, index: QModelIndex) -> None:
"""
This method determines how the data is DRAWN from the model and SHOWN into the editor (QComboBox or whatever)
"""
if not isinstance(editor, QComboBox):
return QItemDelegate.setEditorData(self, editor, index)
# The data is always stored as EditRole into the model!
data = index.data(Qt.EditRole)
# Just to let PyCharm know that now editor is a QComboBox
editor: QComboBox
# Search which is the index corresponding to the data in EditRole (used by QDataWidgetMapper by default)
# NOTE: QComboBox.findData does not work if Enum or its subclasses (e.g., IntEnum) is used!
# I prefer the manual implementation for a wider applicability
items_data = [editor.itemData(i) for i in range(editor.count())]
if not data in items_data:
raise ValueError(f'ComboBox {editor} has no data {data}')
# Change the current index accordingly
editor.setProperty("currentIndex", items_data.index(data))
return
def setModelData(self, editor: QWidget, model: QAbstractItemModel, index: QModelIndex) -> None:
"""... This method does the contrary: determines how the data is taken from the editor and pushed into the model"""
if not isinstance(editor, QComboBox):
# For widgets other than QComboBox, do not do anything than the standard
return QItemDelegate.setModelData(self, editor, model, index)
editor: QComboBox
# Take the data from Qt.UserRole
data = editor.currentData()
# Put the data into the model in EditRole (this is the role generally needed by the QDataWidgetMapper)
model.setData(index, data, Qt.EditRole)
return
class ObjectModel(QAbstractTableModel):
def __init__(self, objects, parent=None):
super(ObjectModel, self).__init__(parent)
self.columns = ['name', 'category']
self.objectList = objects
def rowCount(self, parent=QModelIndex()):
return len(self.objectList)
def columnCount(self, parent=QModelIndex()):
return len(self.columns)
def headerData(self, section, orientation, role=Qt.DisplayRole):
if orientation == Qt.Horizontal and role == Qt.DisplayRole:
return self.columns[section].title()
def data(self, index, role=Qt.DisplayRole):
if role == Qt.DisplayRole or role == Qt.EditRole:
row = self.objectList[index.row()]
column_key = self.columns[index.column()]
return row[column_key]
else:
return None
def setData(self, index, value, role=Qt.EditRole):
if not index.isValid() or role != Qt.EditRole:
return False
if self.objectList:
column_key = self.columns[index.column()]
self.objectList[index.row()][column_key] = value
self.dataChanged.emit(index, index, [])
return True
return True
class CustomView(QWidget):
def __init__(self, parent=None):
super(CustomView, self).__init__(parent)
self.mapper = QDataWidgetMapper()
self.spinboxMapperIndex = QSpinBox()
self.lineEdit = QLineEdit()
self.comboBox = QComboBox()
self.spinboxMapperIndex.valueChanged.connect(self.changeMapperIndex)
self.setupLayout()
def populate_combo(self, categoryList: dict):
"""You just need a method to populate the combobox with the needed data (for DisplayRole and EditRole)"""
self.comboBox.clear()
for category in categoryList:
self.comboBox.addItem(category["name"], category["id"])
def setModel(self, model):
self.mapper.setModel(model)
# Use the custom delegate for the mapper
self.mapper.setItemDelegate(ComboUserDataDelegate(self))
self.mapper.addMapping(self.lineEdit, 0)
self.mapper.addMapping(self.comboBox, 1)
self.mapper.toFirst()
def changeMapperIndex(self, index):
self.mapper.setCurrentIndex(index)
def setupLayout(self):
layout = QVBoxLayout()
layout.addWidget(self.spinboxMapperIndex)
layout.addWidget(self.lineEdit)
layout.addWidget(self.comboBox)
self.setLayout(layout)
if __name__ == '__main__':
app = QApplication(sys.argv)
categoryList = [
{
"id": 23,
"name": "Flower"
},
{
"id": 456,
"name": "Furniture"
}
]
objectList = [
{
"name": "Lotus",
"category": 23
},
{
"name": "Table",
"category": 456
}
]
objectModel = ObjectModel(objectList)
view = CustomView()
view.populate_combo(categoryList)
view.setModel(objectModel)
view.show()
# Just to show the result
view.comboBox.currentIndexChanged.connect(lambda i: print('Current underlying UserData: ', view.comboBox.currentData()))
sys.exit(app.exec_())
Please note:
QStandardItemModel in your case) for populating the QComboBox. Since only name and id is stored, they can be stored directly into each item data (according to relative ItemRole)QItemDelegate is used to translate from the currentData of the QComboBox to the underlying data of the model to be modified, and vice versa. This approach is taken from the article linked earlierIf you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With