Search code examples
python-3.xpyqt5drag-and-dropqt5qlistwidget

Widget inside QListWidgetItem disappears after internal move


I have a QListWidget which is populated by QLabel via .setItemWidget() and a drag and drop mode InternalMove, when I move an item inside the list its label disappears.

How can I solve this issue?

enter image description here

A minimal example to reproduce

from PyQt5.QtWidgets import (
    QApplication, QLabel, QStyle,
    QListWidget, QListWidgetItem
)
from PyQt5.QtCore import QSize
import sys


if __name__ == '__main__':
    app = QApplication(sys.argv)

    list = QListWidget()
    list.setFixedHeight(400)
    list.setDragDropMode(QListWidget.DragDropMode.InternalMove)

    for _ in range(8):
        item = QListWidgetItem()
        item.setSizeHint(QSize(40, 40))
        list.addItem(item)

        label = QLabel()
        label.setPixmap(list.style().standardIcon(
            QStyle.StandardPixmap.SP_ArrowUp).pixmap(QSize(40,40)))
        list.setItemWidget(item, label)

    list.show()

    sys.exit(app.exec())

edit

After reading the documentation for the .setItemWidget() which states:

This function should only be used to display static content in the place of a list widget item. If you want to display custom dynamic content or implement a custom editor widget, use QListView and subclass QStyledItemDelegate instead.

I wonder if this is related to the issue and what does "static content" mean in this context, is QLabel considered "dynamic content"?

edit #2

The problem is inside a dropEvent() a dropMimeData() is called which in turn creates a complete new item? (rowsInserted is called), which isn't supposed to happen for self items I guess, because a widget set in the dragged item isn't serialized and stored inside mimedata so the widget is decoupled, The dropMimeData() is usually called when you drag and drop items from a different list.

So I guess an ugly way to solve this is to store a manually serialized widget inside a QListWidget.mimeData() as a custom mimetype via QMimeData.setData() and recreate the widget after a drop inside QListWidget.dropMimeData().

for example:

from PyQt5.QtWidgets import (
    QApplication, QLabel, QStyle,
    QListWidget, QListWidgetItem
)
from PyQt5.QtCore import QSize, QMimeData, QBuffer, QIODevice
from PyQt5.QtGui import QPixmap
import pickle
import sys

class ListWidget(QListWidget):
    def mimeData(self, items:list[QListWidgetItem]) -> QMimeData:
        mimedata = QListWidget.mimeData(self, items)
        #   e.g. serialize pixmap
        custommime = []
        for item in items:
            label:QLabel = self.itemWidget(item)
            buff = QBuffer()
            buff.open(QIODevice.OpenModeFlag.WriteOnly)
            label.pixmap().save(buff, 'PNG')
            buff.close()
            custommime.append(buff.data())
        mimedata.setData('application/custommime', pickle.dumps(custommime))
        #
        return mimedata 


    def dropMimeData(self, index:int, mimedata:QMimeData, action) -> bool:
        result = QListWidget.dropMimeData(self, index, mimedata, action)
        #   e.g. recreate pixmap
        if mimedata.hasFormat('application/custommime'):
            for i, data in enumerate(
                    pickle.loads(mimedata.data('application/custommime')), 
                    start=index):
                pixmap = QPixmap()
                pixmap.loadFromData(data, 'PNG')
                label = QLabel()
                label.setPixmap(pixmap)
                self.setItemWidget(self.item(i), label)
        #
        return result


if __name__ == '__main__':
    app = QApplication(sys.argv)
    list = ListWidget()
    list.setFixedHeight(400)
    list.setDragDropMode(QListWidget.DragDropMode.InternalMove)
    list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)

    for i in range(8):
        item = QListWidgetItem()
        item.setSizeHint(QSize(40, 40))
        list.addItem(item)
        label = QLabel()
        label.setPixmap(list.style().standardIcon(
            QStyle.StandardPixmap.SP_DialogOkButton + i).pixmap(QSize(40,40)))
        list.setItemWidget(item, label)

    list.show()
    sys.exit(app.exec())

Solution

  • UPDATE

    The bug has now been fixed in the latest versions of Qt5 and Qt6.


    This is caused by a Qt bug which only affects fairly recent versions. I can consistently reproduce it when using Qt-5.15.6 and Qt-6.4.0 - but not e.g. Qt-5.12.1. The issue seems to be closely related to QTBUG-100128.

    A work-around for PyQt5/6 (based on the solution by PaddleStroke) is as follows:

    class ListWidget(QListWidget):
        def dragMoveEvent(self, event):
            if ((target := self.row(self.itemAt(event.pos()))) ==
                (current := self.currentRow()) + 1 or
                (current == self.count() - 1 and target == -1)):
                event.ignore()
            else:
                super().dragMoveEvent(event)
    

    OLD ANSWER:

    Unfortunately, after some further experimentation today, it seems the suggested work-around given below isn't an effective solution. I have found it's also possible to make item-widgets disappear by drag and drop onto non-empty areas.

    After testing some other versions of Qt5, I can confirm that the bug is completely absent in 5.12.x, 5.13.x, 5.14.x, 5.15.0 and 5.15.1. This agrees with the existing Qt bug report above which identified Qt-5.15.2 as the version where the bug was introduced.

    Contrary to what is suggested in the question, there's no reason whatsoever why a label should not be used as an item-widget. The term "static content", just means "not updated by user-defined custom drawing".

    This bug seems to be a regression from QTBUG-87057, which made quite a large number of internal changes to how list-view rows are moved during drag and drop. The complexity of those changes may mean a simple work-around that undoes its negative side-effects isn't possible. The changes affect all Qt5 versions greater than 5.15.1 and Qt6 versions greater than 6.0.


    AFAICS, this only affects dragging and dropping the current last item in the view onto a blank area. Other items and multiple selections aren't affected. This suggests the following work-around:
    class ListWidget(QListWidget):
        def dropEvent(self, event):
            if (self.currentRow() < self.count() - 1 or
                self.itemAt(event.pos()) is not None):
                super().dropEvent(event)
    
    list = ListWidget()
    ...
    

    or using an event-filter:

    class Monitor(QObject):
        def eventFilter(self, source, event):
            if event.type() == QEvent.Drop:
                view = source.parent()
                if (view.currentRow() == view.count() - 1 and
                    view.itemAt(event.pos()) is None):
                    return True
            return super().eventFilter(source, event)
    
    monitor = Monitor()
    list = QListWidget()
    list.viewport().installEventFilter(monitor)
    ...