Home > Software engineering >  pyqt QAbstractItemDelegate not displaying correctly in QListView
pyqt QAbstractItemDelegate not displaying correctly in QListView

Time:12-20

I'm trying to get my head round how Delegates work in pyqt, and wrote the following code to try them. But I can't work out why the paint method doesn't seem to line up the NameAge widget in the items correctly. can anyone advise?

import sys

from PyQt5.QtCore import (
    Qt, QAbstractListModel, QModelIndex)
from PyQt5.QtWidgets import (
    QApplication, QWidget, QListView, QAbstractItemDelegate,
    QHBoxLayout, QLineEdit, QSpinBox, QVBoxLayout)


class NameAge(QWidget):
    def __init__(self, name='', age=0, parent=None):
        super().__init__(parent)

        self.name_edit = QLineEdit(name)
        self.age_spinbox = QSpinBox()
        self.age_spinbox.setValue(age)

        layout = QHBoxLayout()
        layout.addWidget(self.name_edit)
        layout.addWidget(self.age_spinbox)

        self.setLayout(layout)


class NameAgeModel(QAbstractListModel):
    def __init__(self, data, parent=None):
        super().__init__(parent)
        self.data = data

    def rowCount(self, parent=QModelIndex()):
        return len(self.data)

    def data(self, index, role=Qt.DisplayRole):
        if role == Qt.DisplayRole:
            return self.data[index.row()]
        elif role == Qt.EditRole:
            return self.data[index.row()]
        
    def setData(self, index, value, role):
        if role == Qt.EditRole:
            self.data[index.row()] = value
            self.dataChanged.emit(index, index)
            return True
        return False

    def flags(self, index):
        return Qt.ItemIsSelectable | Qt.ItemIsEditable | Qt.ItemIsEnabled


class NameAgeDelegate(QAbstractItemDelegate):
    def setEditorData(self, editor, index):
        name, age = index.data()
        editor.name_edit.setText(name)
        editor.age_spinbox.setValue(age)

    def setModelData(self, editor, model, index):
        name = editor.name_edit.text()
        age = editor.age_spinbox.value()
        model.setData(index, (name, age), Qt.EditRole)

    def createEditor(self, parent, option, index):
        name, age = index.data(Qt.DisplayRole)
        name_age = NameAge(name, age, self.parent())
        name_age.setGeometry(option.rect)
        return name_age

    def paint(self, painter, option, index):
        name, age = index.data(Qt.DisplayRole)
        name_age = NameAge(name, age, self.parent())
        name_age.setGeometry(option.rect)
        name_age.render(painter, option.rect.topLeft())

    def sizeHint(self, option, index):
        name, age = index.data(Qt.DisplayRole)
        name_age = NameAge(name, age, self.parent())
        return name_age.sizeHint()

    def updateEditorGeometry(self, editor, option, index):
        editor.setGeometry(option.rect)


class MainWidget(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        layout = QVBoxLayout()
        self.setLayout(layout)

        self.list_view = QListView()
        self.list_view.setItemDelegate(NameAgeDelegate(self.list_view))
        self.list_view.setModel(NameAgeModel(
            [('Mark', 38), ('John', 30), ('Jane', 25)]))

        layout.addWidget(self.list_view)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    main_widget = MainWidget()
    main_widget.show()
    sys.exit(app.exec_())

screen shot of problems

any advice would be appreciated Sorry as can't find many examples of people trying to do this.

Thanks, Mark

CodePudding user response:

The specific issue is that you're using the targetOffset along with the geometry, while you should only set the size and translate the painter instead:

        name_age.resize(option.rect.size())
        painter.save()
        painter.translate(option.rect.topLeft())
        name_age.render(painter)
        painter.restore()

Unfortunately, this is only the correct solution for the wrong problem, as you're having a much bigger issue: you're constantly creating new instances of NameAge, which is terribly wrong for two reasons:

  1. calls to paint() and sizeHint() are extremely frequent, meaning that you will be creating new widgets hundred (or thousands) of times, even when just hovering items with the mouse or resizing the view;
  2. creating widgets with a parent means that they will exist as long as the parent does; with your code, you can easily get thousands of unused instances;

Another issue your code has is that you're creating the editor with the wrong parent, self.parent(). This is wrong for two reasons:

  • createEditor() already provides the parent, which is the view's viewport (not the view!);
  • the parent argument of the delegate constructor is optional and could even not be a QWidget but a plain QObject (for instance, the QApplication); this means that:
    • if the parent of the delegate is a QObject and not a QWidget, your code will crash as QWidgets only accept another QWidget or None as parent;
    • if the parent is None, the editor will appear as a new top level window;

Now, avoiding all the above depends on your needs.

I'm suspecting that you just want the editor to be shown even when the user isn't editing it, so the proper solution is to use a persistent editor, which can be achieved by calling openPersistentEditor().

With a simple view as QListView, this is quite simple (a QTableView or QTreeView might need different delegates for each column): just ensure that the view opens a persistent editor every time a new row is added to the model, which can be easily done by connecting the rowsInserted signal to a function that calls openPersistentEditor().

Then, in order to get proper behavior (including size hints), you have to keep a reference of the editors based on the index. Note that basic indexes (QModelIndex instances) are "volatile", as the documentation notes:

Note: Model indexes should be used immediately and then discarded. You should not rely on indexes to remain valid after calling model functions that change the structure of the model or delete items. If you need to keep a model index over time use a QPersistentModelIndex.

So, in order to have proper references between indexes and editors, you must use QPersistentModelIndex in a dictionary, and eventually remove the key/value pair in case the editor is destroyed.

Also note that the editor might change the default size hint of an item (especially if created after the new index). To do so, you have to emit sizeHintChanged whenever the editor is created.

Another important aspect to be aware of is that QObjects support the user property, which is the default property of any object. This property is used within Qt to easily set the most important "aspect" of that object.

For instance, QLineEdit has its text property set as user one, while QSpinBox uses value. Qt delegates automatically set and get those "user properties" and apply them when reading or writing to the model.

If you properly implement a qt property for your editor, this simplifies things. The only thing to be aware of is that properties are normally basic objects (so, tuples of different types, as in your case, are not supported). The solution is to implement only what is needed, which, in this case, is the setModelData().

import sys

from PyQt5.QtCore import *
from PyQt5.QtWidgets import *


class NameAge(QWidget):
    changed = pyqtSignal()
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.name_edit = QLineEdit()
        self.age_spinbox = QSpinBox()

        layout = QHBoxLayout()
        layout.addWidget(self.name_edit)
        layout.addWidget(self.age_spinbox)

        self.setLayout(layout)

        self.name_edit.installEventFilter(self)
        self.age_spinbox.installEventFilter(self)

        # alternatively, you can skip the whole event filter aspect, and just
        # connect to the textChanged and valueChanged of the above widgets to
        # the changed signal; this will potentially update the model whenever
        # the value of those widgets is changed by the user.

    @pyqtProperty(object, user=True)
    def data(self):
        return self.name_edit.text(), self.age_spinbox.value()

    @data.setter
    def data(self, data):
        if data is None:
            return
        try:
            name, age = data
            self.name_edit.setText(name)
            self.age_spinbox.setValue(age)
        except (ValueError, TypeError) as e:
            raise e

    def setData(self, data):
        self.data = data

    def eventFilter(self, obj, event):
        if (
            event.type() == event.FocusOut
            and not self.isAncestorOf(QApplication.focusWidget())
        ):
            self.changed.emit()
        return super().eventFilter(obj, event)


class NameAgeModel(QAbstractListModel):
    def __init__(self, data, parent=None):
        super().__init__(parent)
        self.data = data

    def rowCount(self, parent=QModelIndex()):
        return len(self.data)

    def data(self, index, role=Qt.DisplayRole):
        if role in (Qt.DisplayRole, Qt.EditRole):
            return self.data[index.row()]
        
    def setData(self, index, value, role):
        if role == Qt.EditRole:
            self.data[index.row()] = value
            self.dataChanged.emit(index, index)
            return True
        return False

    def flags(self, index):
        return Qt.ItemIsSelectable | Qt.ItemIsEditable | Qt.ItemIsEnabled


class NameAgeDelegate(QStyledItemDelegate):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.editors = {}

    def setModelData(self, editor, model, index):
        model.setData(index, editor.data, Qt.EditRole)

    def createEditor(self, parent, option, index):
        name_age = NameAge(parent=parent)

        pIndex = QPersistentModelIndex(index)
        if pIndex in self.editors:
            self.editors[pIndex].deleteLater()
        self.editors[pIndex] = name_age

        name_age.changed.connect(lambda: self.commitData.emit(name_age))
        name_age.destroyed.connect(lambda: self.editors.pop(pIndex))

        self.sizeHintChanged.emit(index)
        return name_age

    def sizeHint(self, option, index):
        editor = self.editors.get(QPersistentModelIndex(index))
        if editor:
            return editor.sizeHint()
        return super().sizeHint(option, index)

    def updateEditorGeometry(self, editor, option, index):
        # required to avoid some quirks for custom editors when committing data
        editor.setGeometry(option.rect)


class MainWidget(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        layout = QVBoxLayout()
        self.setLayout(layout)

        self.list_view = QListView()
        self.list_view.setItemDelegate(NameAgeDelegate(self.list_view))
        model = NameAgeModel([('Mark', 38), ('John', 30), ('Jane', 25)])
        self.list_view.setModel(model)

        layout.addWidget(self.list_view)

        for row in range(model.rowCount()):
            self.list_view.openPersistentEditor(model.index(row, 0))

        model.rowsInserted.connect(lambda p, row, _: 
            self.list_view.openPersistentEditor(model.index(row, 0)))


if __name__ == '__main__':
    app = QApplication(sys.argv)
    main_widget = MainWidget()
    main_widget.show()
    sys.exit(app.exec_())
  • Related