1

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.

オパラ
  • 317
  • 2
  • 10
  • Well, providing *both* scrolling *and* selection is not easy. It's not impossible, but it's actually difficult, especially if you want to do it in the good way. Unfortunately, I doubt that anybody would provide an answer that would solve both aspects, due to the above complexities. We are glad to help with specific, detailed questions, but what you're asking seems more like "can you do that for me?", since there's absolutely no attempt in trying to achieve what you want in your code. – musicamante Jul 16 '22 at 03:30
  • @musicamante thank you for the insight. I did try to do it with the method suggested in https://stackoverflow.com/a/18983353/14807690 but that didn't quite work out so I thought to scratch it and just ask a question here. I guess in hindsight, I should have started with the other code. I have update the my post accordingly. – オパラ Jul 16 '22 at 05:05
  • Also, Just having the text scrollable is enough, think of the selection feature as a luxury. Though I would appreciate it if I could get a hint as to how both of them can be done. Right now I have little idea as to what I should be looking into even. – オパラ Jul 16 '22 at 05:13
  • 1
    Well, if I've to be honest, creating a new QTextEdit for each paint call is a *terrible* idea: C++ is fast, but let's not overuse its performance for the wrong reasons. The `paint()` function is called *an awful lot of times* for each item, even hundreds of times per second, multiplied for each item that is being shown. We're talking about *thousands* of fully constructed QWidgets created at any given second, and possibly anytime the mouse hovers an item: just put a `print()` within the `paint()` override of the delegate and you'll see. So, no, that's not a viable choice. A possibility is to-> – musicamante Jul 16 '22 at 05:41
  • 1
    ->consider what items should "scroll", and also decide how that scroll would work: constantly? on hover? when focused? Based on that, you can keep an instance reference of the "scrollable" indexes and eventually call for an update on the parent view, and then use the animation framework (or basic QTimer references) to update those indexes when required. As you can see, this is already quite complex. Adding support for text selection is, well, as you said, a luxury, and quite a big one. – musicamante Jul 16 '22 at 05:45
  • @musicamante what do you think about just using a `QWidget` with a `QLabel` and a `QTextEdit` as item widget for each list item, instead of dealing with this with `paint`? – オパラ Jul 17 '22 at 15:35
  • Well, you could, but you should only use widgets when actually necessary, especially because they tend to become unreliable when using drag&drop and they decrease performance with lots of items. The painting solution might be more complex, but it's also the preferable one, since it's *always* based on the index data, doesn't require further widgets nor keeping references – musicamante Jul 17 '22 at 16:28
  • Alright, thank you for all the help @musicamante, I'll keep trying with the `paint` method a couple more days. Also thought of using the editor feature in delegates to have the user click to interact with the text. And if all else fails, I'll switch to a list of custom widgets. – オパラ Jul 17 '22 at 16:36

2 Answers2

1

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;
musicamante
  • 41,230
  • 6
  • 33
  • 58
  • Thank you for the answer, though I think you misunderstood what I meant by "have it be scrollable" in the post. I meant have it be scrollable by the user, as in when they use the mouse wheel while hovered above an item. Not to have it scroll from start to finish automatically when hovered or something. Though this does give me an idea for how I can accomplish the type of scrolling I wanted. – オパラ Jul 18 '22 at 08:16
  • Why do you copy `opt` in `paint`? – オパラ Jul 18 '22 at 13:45
  • 1
    @オパラ Oh, well, this can also be done, but be aware that having the mouse wheel to work like that can be a bit awkward if view requires scrolling (I'd find it very annoying). I'll see if I can update this in the next days. About the copy, it might not be strictly required for this specific case, but it's good practice to do it (Qt also does it in its default implementation), since the same option is always shared between all items for each simultaneous "pass" (like painting), and some properties of the option might be overridden for some items and not restored for the others. – musicamante Jul 19 '22 at 09:33
  • I've come up with a [solution](https://stackoverflow.com/a/73036943/14807690), thanks to you. I did create a copy of `option` but commented out lines that worked on said copy, since I didn't understand the significance of them. Specifically the ones working with `style`: `style.drawPrimitive` and `style.subElementRect`. My guess is they make the the delegate work cross platform? Since that's what I think `QStyle` is for, but I could be wrong. Could you expand on that, please? – オパラ Jul 19 '22 at 12:37
  • Regarding the "having the mouse wheel to work like that can be a bit awkward if view requires scrolling (I'd find it very annoying)", the user can scroll the view when hovering outside the description area. Hopefully that makes it not-awkward/annoying. – オパラ Jul 19 '22 at 12:40
0

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...

オパラ
  • 317
  • 2
  • 10