pythonpython-3.xpyqt5qstyleditemdelegate

How to use an active QComboBox as an element of QListView in PyQt5?


I am using PyQt5 to make an application. One of my widgets will be a QListView that displays a list of required items, e.g. required to cook a particular dish, say.

For most of these, the listed item is the only possibility. But for a few items, there is more than one option that will fulfill the requirements. For those with multiple possibilities, I want to display those possibilities in a functional QComboBox. So if the user has no whole milk, they can click that item, and see that 2% milk also works.

How can I include working combo boxes among the elements of my QListView?

Below is an example that shows what I have so far. It can work in Spyder or using python -i, you just have to comment or uncomment as noted. By "work", I mean it shows the required items in QListView, but the combo boxes show only the first option, and their displays can't be changed with the mouse. However, I can say e.g. qb1.setCurrentIndex(1) at the python prompt, and then when I move the mouse pointer onto the widget, the display updates to "2% milk". I have found it helpful to be able to interact with and inspect the widget in Spyder or a python interpreter, but I still have this question. I know there are C++ examples of things like this around, but I have been unable to understand them well enough to do what I want. If we can post a working Python example of this, it will help me and others too I'm sure.

from PyQt5.QtWidgets import QApplication, QComboBox, QListView, QStyledItemDelegate
from PyQt5.QtCore import QAbstractListModel, Qt

# A delegate for the combo boxes. 
class QBDelegate(QStyledItemDelegate):    
    def paint(self, painter, option, index):
        painter.drawText(option.rect, Qt.AlignLeft, self.parent().currentText())


# my own wrapper for the abstract list class
class PlainList(QAbstractListModel):
    def __init__(self, elements):
        super().__init__()
        self.elements = elements
        
    def data(self, index, role):
        if role == Qt.DisplayRole:
            text = self.elements[index.row()]
            return text

    def rowCount(self, index):
        try:
            return len(self.elements)
        except TypeError:
            return self.elements.rowCount(index)

app = QApplication([])  # in Spyder, this seems unnecessary, but harmless. 

qb0 = 'powdered sugar'  # no other choice
qb1 = QComboBox()
qb1.setModel(PlainList(['whole milk','2% milk','half-and-half']))
d1 = QBDelegate(qb1)
qb1.setItemDelegate(d1)

qb2 = QComboBox()
qb2.setModel(PlainList(['butter', 'lard']))
d2 = QBDelegate(qb2)
qb2.setItemDelegate(d2)

qb3 = 'cayenne pepper'  # there is no substitute

QV = QListView()
qlist = PlainList([qb0, qb1, qb2, qb3])

QV.setModel(qlist)
QV.setItemDelegateForRow(1, d1)
QV.setItemDelegateForRow(2, d2)
QV.show()

app.exec_() #  Comment this line out, to run in Spyder. Then you can inspect QV etc in the iPython console. Handy! 

Solution

  • There are some misconceptions in your attempt.

    First of all, setting the delegate parent as a combo box and then setting the delegate for the list view won't make the delegate show the combo box.

    Besides, as the documentation clearly says:

    Warning: You should not share the same instance of a delegate between views. Doing so can cause incorrect or unintuitive editing behavior since each view connected to a given delegate may receive the closeEditor() signal, and attempt to access, modify or close an editor that has already been closed.

    In any case, adding the combo box to the item list is certainly not an option: the view won't have anything to do with it, and overriding the data() to show the current combo item is not a valid solution; while theoretically item data can contain any kind of object, for your purpose the model should contain data, not widgets.

    In order to show a different widget for a view, you must override createEditor() and return the appropriate widget.

    Then, since you probably need to keep the data available when accessing the model and for the view, the model should contain the available options and eventually return the current option or the "sub-list" depending on the situation.

    Finally, rowCount() must always return the row count of the model, not that of the content of the index.

    A possibility is to create a "nested model" that supports a "current index" for the selected option for inner models.

    Then you could either use openPersistentEditor() or implement flags() and add the Qt.ItemIsEditable for items that contain a list model.

    class QBDelegate(QStyledItemDelegate):
        def createEditor(self, parent, option, index):
            value = index.data(Qt.EditRole)
            if isinstance(value, PlainList):
                editor = QComboBox(parent)
                editor.setModel(value)
                editor.setCurrentIndex(value.currentIndex)
                # submit the data whenever the index changes
                editor.currentIndexChanged.connect(
                    lambda: self.commitData.emit(editor))
            else:
                editor = super().createEditor(parent, option, index)
            return editor
    
        def setModelData(self, editor, model, index):
            if isinstance(editor, QComboBox):
                # the default implementation tries to set the text if the
                # editor is a combobox, but we need to set the index
                model.setData(index, editor.currentIndex())
            else:
                super().setModelData(editor, model, index)
    
    
    class PlainList(QAbstractListModel):
        currentIndex = 0
        def __init__(self, elements):
            super().__init__()
            self.elements = []
            for element in elements:
                if isinstance(element, (tuple, list)) and element:
                    element = PlainList(element)
                self.elements.append(element)
            
        def data(self, index, role=Qt.DisplayRole):
            if role == Qt.EditRole:
                return self.elements[index.row()]
            elif role == Qt.DisplayRole:
                value = self.elements[index.row()]
                if isinstance(value, PlainList):
                    return value.elements[value.currentIndex]
                else:
                    return value
    
        def flags(self, index):
            flags = super().flags(index)
            if isinstance(index.data(Qt.EditRole), PlainList):
                flags |= Qt.ItemIsEditable
            return flags
    
        def setData(self, index, value, role=Qt.EditRole):
            if role == Qt.EditRole:
                item = self.elements[index.row()]
                if isinstance(item, PlainList):
                    item.currentIndex = value
                else:
                    self.elements[index.row()] = value
            return True
    
        def rowCount(self, parent=None):
            return len(self.elements)
    
    app = QApplication([])
    
    qb0 = 'powdered sugar'  # no other choice
    qb1 = ['whole milk','2% milk','half-and-half']
    qb2 = ['butter', 'lard']
    qb3 = 'cayenne pepper'  # there is no substitute
    
    QV = QListView()
    qlist = PlainList([qb0, qb1, qb2, qb3])
    
    QV.setModel(qlist)
    QV.setItemDelegate(QBDelegate(QV))
    
    ## to always display the combo:
    #for i in range(qlist.rowCount()):
    #    index = qlist.index(i)
    #    if index.flags() & Qt.ItemIsEditable:
    #        QV.openPersistentEditor(index)
    
    QV.show()
    
    app.exec_()