1

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! 
Bill Peria
  • 13
  • 2
  • Questions should only contain details that are relevant for its understanding; for the purpose of this question, knowing about your usage in Spyder (or why you find it useful) is completely irrelevant. – musicamante Apr 19 '22 at 22:56

1 Answers1

0

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_()
musicamante
  • 41,230
  • 6
  • 33
  • 58
  • Thank you very much, musicamante! Your example does what I wanted, exactly so after I uncommented the lines to "always show the combo". – Bill Peria Apr 20 '22 at 00:18
  • I want to clarify the part about sharing a delegate between two views. In hindsight, with your help, I think I see that e.g. d1 was a delegate for both qb1 and for row 1 of QV. Is that correct? – Bill Peria Apr 20 '22 at 00:25
  • I appreciate your highlighting of the documentation. I did read, but did not understand, that part. Because you flagged it as the important part, I knew there was something in there I was missing, and was able (I think) to figure it out. Again, thank you. – Bill Peria Apr 20 '22 at 00:27
  • @BillPeria You're very welcome! Btw, the commented lines were in case you wanted to show the combo only when entering editing mode (by default, double clicking or pressing the edit key, which normally is `F2`). About your last comment, yes: as written above, an item delegate should only be set for a single view; while QComboBox is not an "actual" item view, the delegate is set for its popup (which *is* an item view), and if you read the [`setItemDelegate()`](//doc.qt.io/qt-5/qcombobox.html#setItemDelegate) docs there is a similar warning. Besides, the parent argument of the delegate only -> – musicamante Apr 20 '22 at 00:41
  • @BillPeria -> serves as setting the parent *object*, which is normally used only to ensure that the created object is properly deleted when the parent is; while, for widgets, this also makes the created widget as shown "inside" the given parent, delegates are not actual widgets, so their parent isn't normally used for anything else than ensuring memory clean up. I strongly suggest you to do lots of experiments and reading of the documentation, because the Qt item/view is as powerful as it's complex, it takes a lot of time and experience to properly understand how it works. – musicamante Apr 20 '22 at 00:42