2

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.

grouaze
  • 53
  • 8

1 Answers1

0

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:

  1. You don't need a complex model (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)
  2. The 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 earlier
  3. Comments into the code are provided to explain step by step what I've wrote. If further info is needed, I'm sorry and I'll try to provide it to you.
Buzz
  • 1,102
  • 1
  • 9
  • 24