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

Time:07-20

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.

CodePudding user response:

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;

CodePudding user response:

An answer I reached inspired by musicamante's answer and comments:

from collections import defaultdict
import os
import random
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)
DESCRIPTION_OFFSETS = (
    THUMBNAIL_SIZE[0]   (ITEMS_SPACING * 2),
    -THUMBNAIL_SIZE[0] - (ITEMS_SPACING * 2),
)
LOCATION = ""


class EventFilter(qtc.QObject):

    scrolled_signal = qtc.Signal(qtc.QModelIndex, qtc.QPointF)

    def __init__(self, view: qtw.QAbstractItemView):
        super().__init__(view)

        self._view = view

        viewport = self._view.viewport()
        viewport.setMouseTracking(True)
        viewport.installEventFilter(self)

    def eventFilter(self, watched: qtw.QWidget, event: qtc.QEvent):
        if event.type() == qtc.QEvent.Wheel:
            if (
                index := self._view.indexAt((pos := event.position().toPoint()))
            ).isValid():
                item_rect = self._view.rectForIndex(index)
                description_rect = qtc.QRect(
                    item_rect.left()   DESCRIPTION_OFFSETS[0],
                    item_rect.top(),
                    item_rect.width()   DESCRIPTION_OFFSETS[1],
                    THUMBNAIL_SIZE[1],
                )
                if description_rect.contains(pos):
                    self.scrolled_signal.emit(index, event.angleDelta())
                    self._view.update(index)
                    return True

        return False


class Delegate(qtw.QAbstractItemDelegate):

    # Variable to store each index's current scroll value and maximum text height.
    _y_scroll_value: dict[qtc.QModelIndex, tuple[int, int]]

    def __init__(self, view: qtw.QListView, *args, **kwargs) -> None:
        super().__init__(parent=view, *args, **kwargs)

        self._y_scroll_value: dict[qtc.QModelIndex, tuple[int, int]] = defaultdict(
            lambda: (0, 0)
        )

        self._event_filter = EventFilter(view)
        self._event_filter.scrolled_signal.connect(self._scrolled_slot)

    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

        description_rect = qtc.QRect(
            option.rect.left()   DESCRIPTION_OFFSETS[0],
            option.rect.top(),
            option.rect.width()   DESCRIPTION_OFFSETS[1],
            THUMBNAIL_SIZE[1],
        )

        description_style_option = qtw.QStyleOptionViewItem(option)
        # description_style_option.text = description
        # description_style_option.rect = description_rect

        # widget = description_style_option.widget
        # description_style = widget.style() if widget else qtw.QApplication.style()
        # description_style.drawPrimitive(
        #     qtw.QStyle.PE_PanelItemViewItem, description_style_option, painter, widget
        # )
        # text_rect = description_style.subElementRect(
        #     qtw.QStyle.SE_ItemViewItemText, description_style_option, widget
        # )

        description_top = description_rect.top()   self._y_scroll_value[index][0]

        if not self._y_scroll_value[index][1]:
            self._y_scroll_value[index] = (
                self._y_scroll_value[index][0],
                description_style_option.fontMetrics.height()
                * len(description.splitlines()),
            )

        description_rect.setTop(description_top)

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

        painter.drawText(description_rect, description)
        painter.restore()

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

    def _scrolled_slot(self, index: qtc.QModelIndex, scroll_delta: qtc.QPointF):
        _y_scroll_value = self._y_scroll_value[index]
        y = scroll_delta.y() // 10
        if _y_scroll_value[0]   y > 0:
            self._y_scroll_value[index] = (
                0,
                _y_scroll_value[1],
            )
            return
        if _y_scroll_value[0]   y   _y_scroll_value[1] < THUMBNAIL_SIZE[1]:
            self._y_scroll_value[index] = (
                -(_y_scroll_value[1] - THUMBNAIL_SIZE[1]),
                _y_scroll_value[1],
            )
            return
        self._y_scroll_value[index] = (
            _y_scroll_value[0]   y,
            _y_scroll_value[1],
        )


class Model(qtc.QAbstractListModel):
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self._thumbnails = [
            qtg.QImage(LOCATION   filename) for filename in os.listdir(LOCATION)
        ]
        self._descriptions = [
            "\n".join(f"{i} this is text" for i in range(random.choice((50, 70)))) for _ in range(24)
        ]

    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)

        self._model = Model()
        self._view = qtw.QListView(self)
        self._view.setSpacing(ITEMS_SPACING)
        self._view.setVerticalScrollMode(qtw.QListView.ScrollMode.ScrollPerPixel)
        self._view.setModel(self._model)
        self._view.setItemDelegate(Delegate(self._view))

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


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

Now I just need to implement scrollbars...

  • Related