How to paint a composite widget in a QStyledItemDelegate, Pyside6

This FAQ:
shows how to display a screen of thumbnail images using a delegate. It uses the painter.drawImage function to draw the image

Now imagine you want to display a linedit under each thumbnail, editable, which would hold the photo caption. There appears to be no similar function to draw a lineedit, either using “painter.” or QStyle.drawControl

Ideally, I would construct a CaptionedThumbnail that was a QListWidget that had the scaled image and the line edit in a QVBoxlayout. Then display that in the list. In fact I can do that using an item-based approach (using QStandardItemModel instead of QAbstractListModel), but I see warnings that one should not use an item based approach for dynamic data, such as an editable line edit. It appears from the docs that adding edit functions to a delegate requires overriding several methods, and gets complex.

What design approach should I take here? If it is the delegate approach, how do I handle the line edit?
thanks in advance.

Hello @Max_Fritzler,

There is indeed no way to draw a QLineEdit using the QPainter API. I think that’s because, from Qt point of view, there is no reason to draw a line edit, as you can simply draw some text for displaying it. If you really want it to look like a line edit, you can add a rectangle around your text. The line edit would be useful only when you want to edit the data, and that’s where you can use dedicated overridden methods for that. By this way, you show only one line edit when you want to edit a specific data item, and you paint all the other items. Showing a line edit for each item would use more resource and slower to respond, that’s why you see “warnings” about this.

I have modified the code from the FAQ for adding text drawing and methods for editing the data, using a line edit as editor.

p.s. Sorry for modifying the imports, I prefer this way so I don’t need to constantly update my Qt imports.

import glob
import math
import sys
import typing
from collections import namedtuple

from PyQt5 import QtCore as qc
from PyQt5 import QtGui as qg
from PyQt5 import QtWidgets as qw

# Create a custom namedtuple class to hold our data.
preview = namedtuple("preview", "id title image")

NUMBER_OF_COLUMNS = 4
CELL_PADDING = 20 # all sides
TEXT_HEIGHT = 20

class PreviewDelegate(qw.QStyledItemDelegate):

    def createEditor(self, parent:typing.Optional[qw.QWidget], option:qw.QStyleOptionViewItem, index:qc.QModelIndex):
        editor = qw.QLineEdit(parent)
        return editor

    def setEditorData(self, editor:typing.Optional[qw.QWidget], index:qc.QModelIndex):
        data:preview = index.model().data(index, qc.Qt.ItemDataRole.DisplayRole)
        if data is None:
            super().setEditorData(editor, index)
        editor.setText(data.title)

    def setModelData(self, editor:typing.Optional[qw.QWidget], model:qc.QAbstractItemModel, index:qc.QModelIndex):
        text = editor.text()
        model.setData(index, text, qc.Qt.ItemDataRole.DisplayRole)
        # super().setModelData(editor, model, index)

    def updateEditorGeometry(self, editor:typing.Optional[qw.QWidget], option:qw.QStyleOptionViewItem, index:qc.QModelIndex):
        data = index.model().data(index, qc.Qt.ItemDataRole.DisplayRole)
        if data is None:
            return super().updateEditorGeometry(editor, option, index)

        width = option.rect.width() - CELL_PADDING * 2
        height = option.rect.height() - CELL_PADDING * 2

        txt_rect = qc.QRect(
            option.rect.x() + CELL_PADDING,
            option.rect.y() + CELL_PADDING + height,
            width,
            TEXT_HEIGHT
        )
        editor.setGeometry(txt_rect)
        # super().updateEditorGeometry(editor, option, index)

    def paint(self, painter:typing.Optional[qg.QPainter], option:qw.QStyleOptionViewItem, index:qc.QModelIndex):
        # data is our preview object
        data = index.model().data(index, qc.Qt.ItemDataRole.DisplayRole)
        if data is None:
            return

        width = option.rect.width() - CELL_PADDING * 2
        height = option.rect.height() - CELL_PADDING * 2

        # option.rect holds the area we are painting on the widget (our table cell)
        # scale our pixmap to fit
        scaled = data.image.scaled(
            width,
            height,
            aspectRatioMode=qc.Qt.AspectRatioMode.KeepAspectRatio,
        )
        # Position in the middle of the area.
        x = CELL_PADDING + (width - scaled.width()) / 2
        y = CELL_PADDING + (height - scaled.height() - TEXT_HEIGHT) / 2

        painter.drawImage(int(option.rect.x() + x), int(option.rect.y() + y), scaled)

        # Draw the title below the image, looking like a line edit
        txt_rect = qc.QRect(
            option.rect.x() + CELL_PADDING,
            option.rect.y() + CELL_PADDING + height,
            width,
            TEXT_HEIGHT
        )
        painter.drawRect(
            txt_rect,
        )
        painter.drawText(
            txt_rect,
            qc.Qt.AlignmentFlag.AlignCenter,
            data.title,
        )

    def sizeHint(self, option:qw.QStyleOptionViewItem, index:qc.QModelIndex):
        # All items the same size.
        return qc.QSize(300, 200)


class PreviewModel(qc.QAbstractTableModel):
    def __init__(self, todos=None):
        super().__init__()
        # .data holds our data for display, as a list of Preview objects.
        self.previews = []

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

    def setData(self, index:qc.QModelIndex, value:typing.Any, role:qc.Qt.ItemDataRole):
        if role == qc.Qt.ItemDataRole.DisplayRole:
            p:preview = self.previews[index.row() * 4 + index.column()]
            self.previews[index.row() * 4 + index.column()] = preview(p.id, value, p.image)
            return True
        return False

    def data(self, index:qc.QModelIndex, role:qc.Qt.ItemDataRole):
        try:
            data:preview = self.previews[index.row() * 4 + index.column()]
        except IndexError:
            # Incomplete last row.
            return

        if role == qc.Qt.ItemDataRole.DisplayRole:
            return data   # Pass the data to our delegate to draw.

        if role == qc.Qt.ItemDataRole.ToolTipRole:
            return data.title

    def columnCount(self, index:qc.QModelIndex):
        return NUMBER_OF_COLUMNS

    def rowCount(self, index:qc.QModelIndex):
        n_items = len(self.previews)
        return math.ceil(n_items / NUMBER_OF_COLUMNS)


class MainWindow(qw.QMainWindow):
    def __init__(self):
        super().__init__()

        self.view = qw.QTableView()
        self.view.horizontalHeader().hide()
        self.view.verticalHeader().hide()
        self.view.setGridStyle(qc.Qt.PenStyle.NoPen)

        delegate = PreviewDelegate()
        self.view.setItemDelegate(delegate)
        self.model = PreviewModel()
        self.view.setModel(self.model)

        self.setCentralWidget(self.view)

        # Add a bunch of images.
        for n, fn in enumerate(glob.glob("*.jpg")):
            image = qg.QImage(fn)
            item = preview(n, fn, image)
            self.model.previews.append(item)
        self.model.layoutChanged.emit()

        self.view.resizeRowsToContents()
        self.view.resizeColumnsToContents()


app = qw.QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()

Many thanks! I will study this carefully and apply it.

Many thanks for this. I needed this example. I especially appreciated the really clean code, and use of type hinting, which I need to do better.

In the end, I think I have used the basic idea, but in a simpler fashion.
My key discovery was that a listview has a default delegate. I found I could use the default delegate to accomplish what I needed, without needing a custom delegate. A little tweaking in Designer with the list view allowed me to produce a multi-column listview that showed thumbnails with the captions below, and to edit the captions upon pressing Enter or Return, and to call an image editing window when double-clicked.

The solution goes like this:

  • Use a custom QFileSystemModel, and redefine the roles to show the text data that is wanted, in my case the image caption, not the filename.
  • Then define the edit role to edit the desired data, in my case the image caption
  • Capture the mouse double-click, so that that calls another window to edit the image.

Here’s the key bits of the code:

class SourceModel(QFileSystemModel):
    def __init__(self, size: QSize = QSize(300, 200), caption_height: int = 20):
        super().__init__()
        self.size = size
        self.caption_height = caption_height
        self.tags = None
        self.setNameFilters(NAME_FILTERS)
        self.setNameFilterDisables(False)  # No idea why this is necessary.  doc sucks.

    # This part adapted from https://www.pythonguis.com/faq/file-image-browser-app-with-thumbnails/,
    # namely, the pastebin code from the very bottom response.
    def get_thumbnail(self, index: QModelIndex):
        # Open file as image, resize
        if index.model().isDir(index):
            return
        info = self.fileInfo(index)
        filespec = info.filePath()
        pixmap = QPixmap(filespec).scaled(QSize(self.size.width(), self.size.height() - self.caption_height),
                                          Qt.KeepAspectRatio, mode=Qt.SmoothTransformation)
        return pixmap

    def data(self, index: QModelIndex, role):
        if role == QtCore.Qt.DecorationRole:
            # This role customizes icons.  Set the view to icon mode to display this
            return self.get_thumbnail(index)
        elif role == Qt.DisplayRole:
            # This role supplies a string, text, to display
            return self.get_caption(index)
        elif role == Qt.EditRole:
            # This role provides the data that can be edited for the item.
            return self.get_caption(index)
        elif role == Qt.ToolTipRole:
            return f'The filespec is {self.fileInfo(index).filePath()}'
        else:
            return super().data(index, role)

    def get_caption(self, index: QModelIndex):
        # Get file metadata
        # ToDo: move ET.get_tags to folder level
        info = self.fileInfo(index)
        filename = info.fileName()
        filespec = info.filePath()
        if Path(filespec).is_file():
            # use ExifTool, imported from globals to manage metadata
            self.tags = ET.get_tags(filespec, TAGS_OF_INTEREST)
            caption = self.tags[0].get("XMP:Description", '') 
            return caption
        else:
            return filename

Then, in the setup for the main window

# ********* skip lots of standard setup stuff generated by Designer *******
        self.thumbnails.setModel(self.thumbs_proxy)
        self.thumbnails.setRootIndex(self.thumbs_proxy.mapFromSource(model_root_index))
        self.thumbnails.setIconSize(QSize(0, 0))  # Get rid of the folder icon
        self.thumbnails.setEditTriggers(self.thumbnails.EditTrigger.EditKeyPressed | QAbstractItemView.AnyKeyPressed)

        self.thumbnails.doubleClicked.connect(self.thumbnails_double_clicked)

        # ****** Define the connections of signals to slots **********
        self.fldrView.selectionModel().selectionChanged.connect(self.fldr_view_current_changed)

        # *********************** **REALLY IMPORTANT LINE HERE** **********
       # A listview has a default itemDelegate, which sends various signals.  Connect one to capture changes.
        self.thumbnails.itemDelegate().commitData.connect(self.caption_changed)

        self.thumbnails.keyPressEvent = types.MethodType(thumbnail_handler, self.thumbnails)

And here is the thumbnail keypress handler:

def thumbnail_handler(self, event):
    """Special handling for key events.  NOTE WELL, keys may be bound to actions in the designer file MainWindow.py.
    In that case they will be intercepted in some way, and will not trigger this handler.
    See https:\\stackoverflow.com/questions/20420072/use-keypressevent-to-catch-enter-or-return for a more general way."""
    # print(f'Event type is {event.type()}')
    key_name = event.keyCombination().key().name
    # print(f'firing thumbnail_handler with key name "{key_name}" and event modifiers {Qt.ShiftModifier}')
    if event.type() == QtCore.QEvent.KeyPress:
        key = event.key()
        index = self.currentIndex()
        if (key == Qt.Key_Return or key == Qt.Key_Enter) and event.modifiers() in (Qt.NoModifier | Qt.KeypadModifier):
            if index.isValid() and self.state() != QAbstractItemView.EditingState:
                self.edit(index)

Here’s a sample of the result with the rightmost in edit mode:
image

Performance of the code should be equivalent to your example, since both use a delegate. In actual practice, the performance of my code is limited by the time it takes the QFileSystemModel to parse a folder, and convert the images to pixmaps.

Again, thanks for your response, as I learned a lot from it.

1 Like