0
votes

Hi i was reading this article on how to drag and drop widget items which makes sense like

Qt's item views pass around items using the internal application/x-qabstractitemmodeldatalist MIME type

I also read this Question which mainly focuses on string model from Qlinewidget and QTreewidget.

But how can i get a QAbstractItemView.model() of a QFrame which contains multple QtWidgets.

Bottom line Question is: How to move QFrame which contains multple QtWidgets, within QTreeWidget. Please see the example code below: Press the button to add childs and try dragging them in between other childs or the parent of the first level tree hierarchy

from PyQt5.QtWidgets import (QTreeWidget, QTreeWidgetItem, QPushButton, QLabel, QDialog, QVBoxLayout, QApplication, QLineEdit)
from PyQt5.QtWidgets import (QPushButton, QDialog, QTreeWidget,
                             QTreeWidgetItem, QVBoxLayout,
                             QHBoxLayout, QFrame, QLabel, QComboBox,
                             QApplication)

class Ui_MainWindow(object):
    def setupUi(self, MainWindow):
        self.index=0
        MainWindow.setObjectName("MainWindow")
        MainWindow.resize(800, 600)
        self.centralwidget = QtWidgets.QWidget(MainWindow)
        self.centralwidget.setObjectName("centralwidget")
        self.gridLayout = QtWidgets.QGridLayout(self.centralwidget)
        self.gridLayout.setObjectName("gridLayout")
        self.treeWidget = QtWidgets.QTreeWidget(self.centralwidget)
        self.treeWidget.setObjectName("treeWidget")
        self.treeWidget.setFrameShape(QtWidgets.QFrame.StyledPanel)
        self.treeWidget.setFrameShadow(QtWidgets.QFrame.Sunken)
        self.treeWidget.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
        self.treeWidget.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContents)
        self.treeWidget.setAutoScrollMargin(10)
        
        self.treeWidget.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove)
        
        self.treeWidget.setDragEnabled(1)
        self.treeWidget.setDefaultDropAction(QtCore.Qt.MoveAction)
        self.treeWidget.setAnimated(True)
        self.treeWidget.setWordWrap(False)
        self.treeWidget.setExpandsOnDoubleClick(True)
        self.treeWidget.setObjectName("treeWidget")
        self.treeWidget.header().setVisible(False)
        self.treeWidget.header().setHighlightSections(False)
        self.treeWidget.header().setSortIndicatorShown(False)
        self.treeWidget.header().setStretchLastSection(True)

        self.gridLayout.addWidget(self.treeWidget, 0, 0, 1, 1)
        MainWindow.setCentralWidget(self.centralwidget)
        self.menubar = QtWidgets.QMenuBar(MainWindow)
        self.menubar.setGeometry(QtCore.QRect(0, 0, 800, 21))
        self.menubar.setObjectName("menubar")
        MainWindow.setMenuBar(self.menubar)
        self.statusbar = QtWidgets.QStatusBar(MainWindow)
        self.statusbar.setObjectName("statusbar")
        MainWindow.setStatusBar(self.statusbar)

        #initialize top level items
        self.topLevelItem1 = QTreeWidgetItem(self.treeWidget)
        
        
        #add those top level in treewidget
        self.treeWidget.addTopLevelItem(self.topLevelItem1)

        #create button
        self.ButtonWidget=QtWidgets.QPushButton("Press")
        self.ButtonWidget.clicked.connect(self.AddQFrame)
        #add button to tree widget
        self.treeWidget.setItemWidget(self.topLevelItem1, 0, self.ButtonWidget)
        
        
        self.retranslateUi(MainWindow)
        QtCore.QMetaObject.connectSlotsByName(MainWindow)


    def AddQFrame(self):
        #add combo box on button press
        self.index=self.index+1

        #create child item
        self.ChildItem1 = QTreeWidgetItem()
        self.topLevelItem1.addChild(self.ChildItem1)

        #create frame & horizontal layout for that child item
        self.ChildWidgetFrame=QFrame(self.treeWidget)
        self.layoutChild=QHBoxLayout(self.ChildWidgetFrame)
        
        #layout in qframe
        self.QcomboWidget=QtWidgets.QComboBox()
        self.QcomboWidget.addItem("item "+str(self.index))
        self.QcomboWidget.addItem("CC")
        self.QcomboWidget.addItem("CV")
        self.spacerItem3 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
        #widgets in layout
        self.layoutChild.addWidget(QLabel("#"+str(self.index)))
        self.layoutChild.addWidget(self.QcomboWidget)
        self.layoutChild.addItem(self.spacerItem3)
        #display widget
        self.treeWidget.setItemWidget(self.ChildItem1, 0, self.ChildWidgetFrame)
        

    def retranslateUi(self, MainWindow):
        _translate = QtCore.QCoreApplication.translate
        MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow"))
        self.treeWidget.setSortingEnabled(False)
        self.treeWidget.headerItem().setText(0, _translate("MainWindow", "1"))
        __sortingEnabled = self.treeWidget.isSortingEnabled()
        self.treeWidget.setSortingEnabled(False)
        self.treeWidget.setSortingEnabled(__sortingEnabled)
        
    

if __name__ == "__main__":
    import sys
    app = QtWidgets.QApplication(sys.argv)
    MainWindow = QtWidgets.QMainWindow()
    ui = Ui_MainWindow()
    ui.setupUi(MainWindow)
    MainWindow.show()
    sys.exit(app.exec_())

EDIT

The question was confusing in terms of dragging of which treelevel. So i edited the question: I am only rearranging/moving childs/parents in first hierarchy . Thanks for your time and efforts!

EDIT2

Another edit to clarify the level of treewidget hierarchy.

This is what i am trying to achieve: Dnd Parent Qframe and its child upto first level of tree.

Example Image

1

1 Answers

1
votes

Premise

What the OP is trying to achieve is not easy. Index widgets are not related to the underlying model (and they shouldn't!), since they are only related to the item view. Since drag&drop operations act on QMimeData objects and their contents (which are serialized as byte data), there is no direct way to access an index widget from a drop event. This means that d&d actions only act on the item model, and index widgets are completely ignored.

But, that's not enough.
Even if you can get a byte reference to the index widget, those widgets are always removed as soon as an index widget is replaced or deleted: the main problem with setItemWidget() is the same with setIndexWidget():

If index widget A is replaced with index widget B, index widget A will be deleted.

The source code does the following:

void QAbstractItemView::setIndexWidget(const QModelIndex &index, QWidget *widget)
{
    # ...
    if (QWidget *oldWidget = indexWidget(index)) {
        d->persistent.remove(oldWidget);
        d->removeEditor(oldWidget);
        oldWidget->removeEventFilter(this);
        oldWidget->deleteLater();
    }
    # ...

}

The result is that whenever an index widget is set (or an index is removed), the related index widget is deleted. From the PyQt side of things, we have NO control over this, unless we do a thorough implementation of the related item view class (and... good luck with that).

Note on tree models

Qt has its own way to support the InternalMove flag for tree models. In the following explanation I'm assuming that the drag/drop operation always happen with the SingleSelection mode set for the selectionMode() property, and that the dragDropMode() is set to the default InternalMove. If you want to provide an implementation for advanced drag&drop mode with extended selection, you'll have to do find your own (possibly by studying the source code of QAbstractItemView and QTreeView).

[Workaround] solution

There's a hack, though.
The only widget on which deleteLater() is called is the actual widget set with setIndexWidget(), not its children.
So, in these cases, to add support for drag&drop with index widgets, the only simple solution is to always add an index widget with a container parent widget, and remove the actual widget from the container before replacing/removing the index widget, then create a new container for the actual widget before using setIndexWidget() (or setItemWidget()) on the new index/item, possibly with recursive functions to ensure that child references are kept.

This ensures that the (previous) index widget that is actually shown is not deleted, since only its container is going to, allowing us to set that widget for another index.

Luckily, QTreeWidget has easier access to the items, as the items are actual and persistent objects that can be tracked even after they've been moved (unlike what happens with a QModelIndex in QTreeView).

In the following example (updated with the information provided in the comments) I'm creating top level items and allowing drop only on the first level. This is a basic example, you might want to add some features: for example, prevent drop if an item combo is not set to "Repeat", or even create a new parent item already set as "Repeat" and manually add children.

class WidgetDragTree(QtWidgets.QTreeWidget):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.header().hide()
        self.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove)
        self.setDragEnabled(True)
        self.setDefaultDropAction(QtCore.Qt.MoveAction)

    def addFrame(self):
        item = QtWidgets.QTreeWidgetItem()
        self.addTopLevelItem(item)
        item.setExpanded(True)

        # create the "virtual" container; use 0 contents margins for the layout 
        # to avoid unnecessary padding around the widget
        container = QtWidgets.QWidget(self)
        layout = QtWidgets.QHBoxLayout(container)
        layout.setContentsMargins(0, 0, 0, 0)

        # the *actual* widget that we want to add
        widget = QtWidgets.QFrame()
        layout.addWidget(widget)
        frameLayout = QtWidgets.QHBoxLayout(widget)

        widget.label = QtWidgets.QLabel('#{}'.format(self.topLevelItemCount()))
        frameLayout.addWidget(widget.label)
        combo = QtWidgets.QComboBox()
        frameLayout.addWidget(combo)
        combo.addItems(['Select process', 'CC', 'VV', 'Repeat'])

        # add a spacer at the end to keep widgets at their minimum required size
        frameLayout.addStretch()

        # the widget has to be added AT THE END, otherwise its sizeHint won't be 
        # correctly considered for the index
        self.setItemWidget(item, 0, container)

    def delFrame(self):
        for index in self.selectedIndexes():
            item = self.itemFromIndex(index)
            if item.parent():
                item.parent().takeChild(item)
            else:
                self.takeTopLevelItem(index.row())

    def updateLabels(self, parent=None):
        if parent is None:
            parent = self.rootIndex()
        for row in range(self.model().rowCount(parent)):
            index = self.model().index(row, 0, parent)
            container = self.indexWidget(index)
            if container and container.layout():
                widget = container.layout().itemAt(0).widget()
                try:
                    widget.label.setText('#{}'.format(row + 1))
                except Exception as e:
                    print(e)
            # if the index has children, call updateLabels recursively
            if self.model().rowCount(index):
                self.updateLabels(index)

    def dragMoveEvent(self, event):
        super().dragMoveEvent(event)
        if self.dropIndicatorPosition() == self.OnViewport:
            # do not accept drop on the viewport
            event.ignore()
        elif self.dropIndicatorPosition() == self.OnItem:
            # do not accept drop beyond the first level
            target = self.indexAt(event.pos())
            if target.parent().isValid():
                event.ignore()

    def getIndexes(self, indexList):
        # get indexes recursively using a set (to get unique indexes only)
        indexes = set(indexList)
        for index in indexList:
            childIndexes = []
            for row in range(self.model().rowCount(index)):
                childIndexes.append(self.model().index(row, 0, index))
            if childIndexes:
                indexes |= self.getIndexes(childIndexes)
        return indexes

    def dropEvent(self, event):
        widgets = []
        # remove the actual widget from the container layout and store it along 
        # with the tree item
        for index in self.getIndexes(self.selectedIndexes()):
            item = self.itemFromIndex(index)
            container = self.indexWidget(index)
            if container and container.layout():
                widget = container.layout().itemAt(0).widget()
                if widget:
                    container.layout().removeWidget(widget)
                    widgets.append((item, widget))

        super().dropEvent(event)

        # restore the widgets in a new container
        for item, widget in widgets:
            container = QtWidgets.QWidget(self)
            layout = QtWidgets.QHBoxLayout(container)
            layout.setContentsMargins(0, 0, 0, 0)
            layout.addWidget(widget)
            self.setItemWidget(item, 0, container)
            index = self.indexFromItem(item)
            if index.parent().isValid():
                self.expand(index.parent())

        # force the update of the item layouts
        self.updateGeometries()

        # update the widget labels
        self.updateLabels()


class Test(QtWidgets.QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        layout = QtWidgets.QVBoxLayout(self)
        btnLayout = QtWidgets.QHBoxLayout()
        layout.addLayout(btnLayout)
        self.addBtn = QtWidgets.QPushButton('+')
        btnLayout.addWidget(self.addBtn)
        self.delBtn = QtWidgets.QPushButton('-')
        btnLayout.addWidget(self.delBtn)
        self.tree = WidgetDragTree()
        layout.addWidget(self.tree)

        self.addBtn.clicked.connect(self.tree.addFrame)
        self.delBtn.clicked.connect(self.tree.delFrame)

Update (windows fix)

There seems to be a possible bug, which happens with Windows (at least with Qt 5.13 and Windows 10): after clicking on an item and then clicking on a combobox, the tree widget receives a bunch of mouseMoveEvent that triggers the drag. Unfortunately, I cannot make further testing, but this is a possible workaround:

class WidgetDragTree(QtWidgets.QTreeWidget):
    # ...
    def mousePressEvent(self, event):
        # fix for [unknown] bug on windows where clicking on a combo child of an 
        # item widget also sends back some mouseMoveEvents
        item = self.itemAt(event.pos())
        if item and self.itemWidget(item, 0):
            # if the item has a widget, make a list of child combo boxes
            combos = self.itemWidget(item, 0).findChildren(QtWidgets.QComboBox)
            underMouseWidget = QtWidgets.QApplication.widgetAt(event.globalPos())
            if underMouseWidget in combos:
                return
        super().mousePressEvent(event)