Search code examples
qtpysidepyside6qt6

Qt: How to draw scrollable text on list widget item using QAbstractItemDelegate.paint()?


I want to create a GUI that lists images in a directory along with a description for the images. So far I've been able to create a custom delegate for a QListView to draw the image onto each list item, but any description longer than the designated size of a list item is truncated, how do I have it be scrollable? And also preferably selectable.
My code so far:

import os
from typing import Union

from PySide6 import QtWidgets as qtw
from PySide6 import QtGui as qtg
from PySide6 import QtCore as qtc


ITEMS_SPACING = 10
THUMBNAIL_SIZE = (200, 200)


class Delegate(qtw.QAbstractItemDelegate):
    def paint(
        self,
        painter: qtg.QPainter,
        option: qtw.QStyleOptionViewItem,
        index: qtc.QModelIndex
    ) -> None:
        thumbnail: qtg.QImage = index.model().data(
            index, qtc.Qt.ItemDataRole.DecorationRole
        )
        description: str = index.model().data(
            index, qtc.Qt.ItemDataRole.DisplayRole
        )

        if thumbnail is None:
            return

        old_painter = painter.device()

        painter.drawImage(
            qtc.QRect(
                option.rect.left(),
                option.rect.top(),
                *THUMBNAIL_SIZE
            ),
            thumbnail
        )

        painter.end()

        text_edit = qtw.QPlainTextEdit(self.parent())
        text_edit.setPlainText(description)
        text_edit.setFixedSize(
            option.rect.width() - THUMBNAIL_SIZE[0] - (ITEMS_SPACING * 2),
            THUMBNAIL_SIZE[1]
        )
        text_edit.render(
            painter.device(),
            qtc.QPoint(
                option.rect.left() + THUMBNAIL_SIZE[0] + 20,
                option.rect.top()
            )
        )

        painter.begin(old_painter)

    def sizeHint(
        self, option: qtw.QStyleOptionViewItem, index: qtc.QModelIndex
    ) -> int:
        return qtc.QSize(*THUMBNAIL_SIZE)


class Model(qtc.QAbstractListModel):
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self._thumbnails = [
            qtg.QImage(filename) for filename in os.listdir('.')
        ]
        self._descriptions = ["this is text" * 5000 for _ in range(len(self._thumbnails))]

    def rowCount(self, _: qtc.QModelIndex) -> int:
        return len(self._thumbnails)

    def data(
        self, index: qtc.QModelIndex, role: qtc.Qt.ItemDataRole
    ) -> Union[int, None]:
        if not index.isValid():
            return None
        
        if role == qtc.Qt.ItemDataRole.DisplayRole:
            return self._descriptions[index.row()]
        elif role == qtc.Qt.ItemDataRole.DecorationRole:
            return self._thumbnails[index.row()]
        
        return None


class MainWindow(qtw.QMainWindow):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        delegate = Delegate(self)
        self._model = Model()
        self._view = qtw.QListView(self)
        self._view.setSpacing(ITEMS_SPACING)
        self._view.setModel(self._model)
        self._view.setItemDelegate(delegate)

        self.setCentralWidget(self._view)
        self.show()


app = qtw.QApplication()
mw = MainWindow()
app.exec()

Edit:
Why the code doesn't work:
I can't scroll the rendered QTextEdit nor can I select the text. It's basically the same as painter.drawText but with a scroll bar drawn below the text. Further more, in my actual code I can't get the QTextEdit to align with the images, even though it has the same code as the one provided above. Also, the QTextArea(s) seemingly randomly disappear/reappear following a paint event.


Solution

  • One possible solution is to use a delegate that will "scroll" the text whenever necessary, and also listen for mouse events that will eventually update the "hovered" index (if any) by scrolling its contents.

    Note that we cannot just use the editorEvent() of the delegate, because it can only track standard mouse events (press, release, move), which means that we cannot be notified whenever the mouse leaves an index for an empty area of the view.

    So, we first need to install a separate a custom QObject that acts as filter on the viewport (and enable mouse tracking) and emits a signal whenever the hovered index changes (including invalid indexes - aka, the viewport).

    Then, using a couple of timers, we update that index: the first timer is to delay the scrolling just a bit (so that it doesn't start scrolling unnecessarily when we move between many items), while the second is the actual scrolling timer.

    Finally, the painting function actually draws an empty item based on the current style, then draws the text based on the scrolled value: if the text doesn't need scrolling, we just draw the text, if the scroll is active but the text has reached the right edge, we also stop the above timer to prevent further and unnecessary scrolling.

    from PyQt5.QtWidgets import *
    from PyQt5.QtCore import *
    from PyQt5.QtGui import *
    
    class HoverFilter(QObject):
        indexHovered = pyqtSignal(QModelIndex)
        def __init__(self, view):
            viewport = view.viewport()
            super().__init__(viewport)
            self.view = view
            viewport.setMouseTracking(True)
            viewport.installEventFilter(self)
    
        def eventFilter(self, obj, event):
            if event.type() == event.MouseMove:
                self.indexHovered.emit(self.view.indexAt(event.pos()))
            elif event.type() == event.Leave:
                self.indexHovered.emit(QModelIndex())
            return super().eventFilter(obj, event)
    
    
    class ScrollDelegate(QStyledItemDelegate):
        hoverIndex = None
        hoverX = 0
        def __init__(self, view):
            super().__init__(view)
            self.hoverFilter = HoverFilter(view)
            self.hoverFilter.indexHovered.connect(self.hoverIndexChanged)
            self.scrollTimer = QTimer(self, interval=25, timeout=self.updateHoverIndex)
            self.scrollDelay = QTimer(self, singleShot=True, 
                interval=500, timeout=self.scrollTimer.start)
    
        def hoverIndexChanged(self, index):
            if self.hoverIndex == index:
                return
            if self.hoverIndex and self.hoverIndex.isValid():
                self.parent().update(self.hoverIndex)
            self.hoverIndex = index
            self.scrollTimer.stop()
            if index.isValid():
                self.scrollDelay.start()
                self.hoverX = 0
            else:
                self.scrollDelay.stop()
    
        def updateHoverIndex(self):
            self.parent().update(self.hoverIndex)
            self.hoverX += 1
    
        def paint(self, qp, opt, index):
            opt = opt.__class__(opt)
            self.initStyleOption(opt, index)
            widget = opt.widget
            style = widget.style() if widget else QApplication.style()
            style.drawPrimitive(style.PE_PanelItemViewItem, opt, qp, widget)
    
            if not opt.text:
                return
    
            qp.save()
            qp.setClipRect(opt.rect)
            textRect = style.subElementRect(style.SE_ItemViewItemText, opt, widget)
            margin = style.pixelMetric(style.PM_FocusFrameHMargin, opt, widget) + 1
            left = textRect.x() + margin
            if index == self.hoverIndex:
                textWidth = opt.fontMetrics.boundingRect(opt.text).width()
                if textWidth + margin * 2 > textRect.width():
                    left -= self.hoverX
                    if left + textWidth + margin <= textRect.right():
                        self.scrollTimer.stop()
            textRect.setLeft(left)
            alignment = index.data(Qt.TextAlignmentRole)
            if alignment is None:
                alignment = Qt.AlignLeft|Qt.AlignVCenter
            if opt.state & style.State_Enabled:
                if opt.state & style.State_Active:
                    colorGroup = QPalette.Normal
                else:
                    colorGroup = QPalette.Inactive
            else:
                colorGroup = QPalette.Disabled
            if opt.state & style.State_Selected:
                qp.setPen(opt.palette.color(colorGroup, QPalette.HighlightedText))
            else:
                qp.setPen(opt.palette.color(colorGroup, QPalette.Text))
            qp.drawText(textRect, alignment, opt.text)
            qp.restore()
    
    
    if __name__ == "__main__":
        import sys
        from random import choice, randrange
        from string import ascii_lowercase, ascii_uppercase
        letters = ascii_lowercase + ascii_uppercase
    
        app = QApplication(sys.argv)
        table = QTableWidget(15, 2)
        table.setItemDelegate(ScrollDelegate(table))
        for r in range(15):
            for c in range(2):
                text = ''.join(choice(letters) for i in range(randrange(40)))
                table.setItem(r, c, QTableWidgetItem(text))
        table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
        table.show()
        sys.exit(app.exec())  
    

    Note that, while the above works, there are some important caveats:

    • I didn't really implement the alignment part: I didn't test it with right alignment nor (most importantly) RTL languages; I'll leave that to the reader;
    • it doesn't provide any text selection; if you want that (and don't need editing), consider overriding the createEditor() of the delegate and provide a readOnly line edit, then use openPersistentEditor() on the view: it won't provide actual scrolling, but that's another story (see the following point);
    • the whole scrolling concept might be fine for simple and controlled scenarios, but it's generally not a very good idea from the UX perspective: if the text is very long, users will have to wait for a lot of time, and if they accidentally move the mouse away while waiting for the whole text to appear, they are back to square one - that's extremely annoying; I strongly suggest other ways to show the content, like using the Qt.ToolTipRole or override the helpEvent() of the delegate to show a custom (eventually formatted) tool tip;