2

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())
err69
  • 317
  • 1
  • 7
  • I've seen this behavior in the past [as partially reported in this question](https://stackoverflow.com/q/70128150), but I've never been able to reproduce it consistently: most of the times it works, but *sometimes* it does what you're showing. It's worth noticing that if you try to resize the window, the widget reappears again, so it seems that there's some issue with the geometry of the index widget. Maybe forcing a delayed `updateGeometries` upon a dropEvent override might help, but I'm not really sure, since, as said, I can only reproduce this completely randomly. – musicamante Oct 31 '22 at 15:41
  • @musicamante For me it's constantly reproducible if I drag the last item and drop it on empty area of a `QListWidget` as shown on the image I've posted, I did some digging since I've posted it seems the widgets only disappear when a row is removed from `QListWidget`'s model, basically when `QListWidget.model().rowsRemoved` is emmited. – err69 Oct 31 '22 at 16:32
  • But this shouldn't be your case, since the index is not removed, but just moved: QListWidget internally starts by calling `beginMoveRows()` and the related functions, so the widget *is* moved along with it, it just isn't properly updated. Since you're able to always reproduce the issue, we could try to understand where the problem is: start by creating a function that prints the geometries of all item widgets and call it with a delayed QTimer when rows are moved: `list.model().rowsMoved.connect(lambda: QTimer.singleShot(100, somefunc))`. – musicamante Oct 31 '22 at 17:08
  • The function could be just a simple `for i in range(list.count()):` `print(list.itemWidget(list.item(i)).geometry())`. This obviously is based on your example above, with an widget set for all items. See if the geometries are consistent (especially the last one, which is the item that has been moved to the end): the sizes should all be the same, while the y coordinate should always be the sum of the previous heights. If they are, then change the function above with one that checks for the visibility (`list.itemWidget(list.item(i)).isVisible()`). If not, post the output in your question. – musicamante Oct 31 '22 at 17:14
  • @musicamante That's the problem, when I call `itemWidget()` on an `item` that I've moved the result is `None` basically the `item` itself is still in the list but the `widget` set with `setItemWidget()` gets decoupled. – err69 Oct 31 '22 at 18:56
  • That seems unlikely (but not impossible, as it *might* be a bug). In any case, with the code suggested above, I always get the proper widget. I'd suggest you to add your latest test(s) to your question or at least put them into a pastebin. – musicamante Oct 31 '22 at 19:53
  • 1
    @musicamante It's a bug. What version of Qt are you using? The behaviour is consistent in both Qt-5.15.6 and Qt-6.4.0 on my Linux system. Specifically, when the current last item in the list is dropped on a blank area, the reference to the index widget is lost. Internally, they're reparented to the viewport and the widget-pointer is added to a QSet. Keeping an extra reference in a list on the Python side makes no difference, so something is explicitly deleting those references on the Qt side. I also just tested with Qt-5.12.1, and the bug isn't present there. – ekhumoro Nov 01 '22 at 01:48
  • @ekhumoro Huh! I didn't realize that the issue was just for the *last* item (the animation was too fast). And, yes, I'm using an older Qt version for my default setup (I cannot upgrade it for unrelated work reasons), but considering the other post I linked, I just assumed it was the same issue. My bad... – musicamante Nov 01 '22 at 04:30

2 Answers2

3

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)
...
ekhumoro
  • 115,249
  • 20
  • 229
  • 336
  • 1
    For me the bug is consistent for the last item, but sometimes other items affected too. – nulladdr Nov 01 '22 at 05:54
  • 1
    @nulladdr can confirm other items affected too it's just not consistent, the answer is a solution for the last item only. – err69 Nov 01 '22 at 07:01
  • @err69 Damn. After I re-tested today I got different behaviour. At the moment, I can't see any other obvious way to work around the issue. I've updated my answer with some more info. Can you confirm that you are using a version of Qt5 that is 5.15.2 or later? – ekhumoro Nov 01 '22 at 18:49
  • @nulladdr Please state the specific version of Qt5 you are using. – ekhumoro Nov 01 '22 at 18:50
  • @ekhumoro I'm using `pyqt6` – nulladdr Nov 02 '22 at 05:59
  • @ekhumoro I tested on `5.15.7` and `6.4.0` versions, same result. – err69 Nov 02 '22 at 09:49
  • @err69 So: create a venv, activate it and do `pip install pyqt5==5.15.1`. Then run your original example, and you should see that the issue goes away. The latest edit to your question doesn't prove anything, and isn't really a solution because it just bypasses everything with a crude reimplementation. There's actually no need to serialise anything. The correct way to do things is to simply update the geometry of the item-widget. The problem is that Qt currently has a bug somewhere that loses the internal reference to the widget during the drag-drop process. – ekhumoro Nov 02 '22 at 13:07
  • @err69 PS: see the second update to my answer for the likely source of the bug. – ekhumoro Nov 02 '22 at 14:01
  • @ekhumoro It's failing to install on my system I get `Preparing metadata (pyproject.toml) ... error`, guess I'll try 6.0.* versions. – err69 Nov 02 '22 at 14:27
  • @err69 **All** of the Qt-6.x versions have the bug. It's only Qt-5.15.1 and earlier that don't. *Do not attempt to install earlier versions on your main system*. If you want to test things, create a new [venv](https://docs.python.org/3/library/venv.html#module-venv) and install the earlier versions in that. What platform are you on? – ekhumoro Nov 02 '22 at 16:19
  • @ekhumoro I'm on windows 10, I always use different environments for different projects, thank you . – err69 Nov 02 '22 at 18:52
  • I have this bug and tested. It seems that the widget disappear if you drop the item on itself just below (above works). If the item is the last of the list, then dropping in an empty area will disappear too because you'r dropping it below itself. I think the item is replacing itself and loose the widget by doing so. – PaddleStroke Mar 24 '23 at 09:05
  • I think a way to bypass this bug would be to reject the drop if the row doesn't change. Is there a way to do this? – PaddleStroke Mar 24 '23 at 09:47
1

Here' s a way to prevent the bug from happening :

class QListWidgetDragBugFix : public QListWidget
{
    Q_OBJECT

public:
    QListWidgetDragBugFix(QWidget *parent);
    ~QListWidgetDragBugFix() override;

protected:
    void dragMoveEvent(QDragMoveEvent *e) override;
};


QListWidgetDragBugFix::QListWidgetDragBugFix(QWidget * parent)
  : QListWidget(parent)
{
}

QListWidgetDragBugFix::~QListWidgetDragBugFix()
{
}

/* Qt has a recent bug (2023, https://bugreports.qt.io/browse/QTBUG-100128) 
* where the items disappears in certain conditions when drag and dropping.
* Here we prevent the situation where this happens.
* 1 - If the item is dropped on the item below such that the item doesn't move (ie superior half of the below item)
* 2 - The item is the last one and user drop it on the empty space below.
* In both those cases the item widget was lost.
 */
void QListWidgetCustom::dragMoveEvent(QDragMoveEvent *e)
{
    if ((row(itemAt(e->pos())) == currentRow() + 1) 
        || (currentRow() == count() - 1 && row(itemAt(e->pos())) == -1)) {
        e->ignore();
    }
    else {
        QListWidget::dragMoveEvent(e);
    }
}
PaddleStroke
  • 63
  • 1
  • 6