Search code examples
pythonpyside2qtreewidget

How do you only allow user to change the item order in a QtreeWidget by drag and drop without creating child items?


I have a GUI designed with QtDesigner and a QTreeWidget in it while programming the logic behind it with PySide2. Now I want the user to be able to swap the elements in the QTreeWidget by dragging and dropping, but without changing the hierarchy. So basically I don't want him to be able to insert an item into another item as a child or make a child item a top level item.

This is my QtreeWidget:

parent1
 |child1
 |child2
parent2
parent3

He should only be able to change the order of the parent items or the order of the child item, but not make one the child of an item or make one the parent of an item by drag and drop. I already tried experimenting with the settings in the QtDesigner and changing some values in the code for my QTreeWidget item, but nothing works. I would be really happy if someone could guide me on the right path about this.


Solution

  • EDIT: the answer has been updated, please ensure that you read all of it

    Qt Designer doesn't allow to set such behavior, and while the item editor provides per item flags, it's not "fully" implemented: it does provide the flag for ItemIsDropEnabled, but that's unchecked by default and even checking/unchecking it doesn't allow to "unset" that flag.

    The result is that the tree widget will be created with the default QTreeWidgetItemFlags, which automatically sets that flag.

    The simplest solution is to create a function that iters the top level items and disables that flag, but also calls a recursive one that disables the ItemIsDragEnabled for child items.

    That function must be called as soon as the tree widget is created if the structure already has items, and is also connected to the model's rowsInserted signal so that it's updated everytime a new row is added, including for child items.

    NOTE: this only works when the manual sorting is required amongst top level items, see below for an implementation that allows sorting for child items.

    class MainWindow(QtWidgets.QMainWindow):
        def __init__(self):
            # ...
            self.checkTreeParents()
            self.treeWidget.model().rowsInserted.connect(self.checkTreeParents)
    
        def checkTreeParents(self):
            disabledFlags = QtCore.Qt.ItemIsDragEnabled | QtCore.Qt.ItemIsDropEnabled
            def checkChildren(parent):
                for row in range(parent.childCount()):
                    child = parent.child(row)
                    child.setFlags(child.flags() & ~disabledFlags)
                    checkChildren(child)
            root = self.treeWidget.invisibleRootItem()
            for row in range(root.childCount()):
                child = root.child(row)
                child.setFlags(child.flags() & ~QtCore.Qt.ItemIsDropEnabled)
                checkChildren(child)
    

    UPDATE

    As stated, the above implementation works only because it's easy to make a distinction between a top level item and child one: the former always has an invalid QModelIndex. If sorting is required between child items, a different route has to be taken.

    While the following can be achieved without subclassing (using "monkey patching") that path is not usually suggested, as it often leads to silent errors and bugs that are difficult to track.

    The requirement is to use a promoted widget (I suggest to read my related answer and do some research about the subject), so that the tree widget can be correctly implemented.

    The "trick" is to override the startDrag function, get a list of the whole tree index, pair all items with their current flags, disable the ItemIsDropEnabled flag for all items except for the parent of the dragged item(s); then restore the flags right after the drag operation. Since startDrag is blocking (it starts its own "event loop" and returns after it exits), restoring flags after calling the default implementation is safe enough.
    This ensures that drag events will only be accepted when hovering over the same parent as the selected items or between them, but not on them or on/between any other item or parent (including children).

    This is probably the best method, as trying to do the same by overriding dragEnterEvent, dragMoveEvent and dropEvent would be actually more complex (thus, prone to bugs), and would probably also require overriding paintEvent in order to correctly display the drop indicator. By temporarily changing the drop flags of the items, we let the QTreeView take care of all of that.

    Note: the following assumes that you promoted the tree widget using TreeView as class name; please ensure that you've understood how widget promotion works.

    class TreeView(QtWidgets.QTreeWidget):
        def iterItems(self, parent=None):
            # iter through **all** items in the tree model, recursively, and
            # yield each item individually
            if parent is None:
                parent = self.invisibleRootItem()
                # the root item *must* be yield! If not, the result is that the
                # root will not have the ItemIsDropEnabled flag set, so it 
                # will accept drops even from child items 
                yield parent
            for row in range(parent.childCount()):
                childItem = parent.child(row)
                yield childItem
                for grandChild in self.iterItems(childItem):
                    # yield children recursively, including grandchildren
                    yield grandChild
            
    
        def startDrag(self, actions):
            selected = [i for i in self.selectedIndexes() 
                if i.flags() & QtCore.Qt.ItemIsDragEnabled]
            parents = list(set(i.parent() for i in selected))
            # we only accept drags from children of a single item
            if len(parents) == 1:
                parent = self.itemFromIndex(parents[0])
                if not parent:
                    # required since itemFromIndex on the root *index* returns None
                    parent = self.invisibleRootItem()
            else:
                # no item will accept drops!
                parent = None
            itemFlags = []
            for item in self.iterItems():
                if item != parent:
                    # store all flags and disable the drop flag if set, UNLESS the 
                    # item is the parent
                    flags = item.flags()
                    itemFlags.append((item, flags))
                    item.setFlags(flags & ~QtCore.Qt.ItemIsDropEnabled)
    
            # call the default implementation and let the tree widget
            # do all of its stuff
            super().startDrag(actions)
    
            # finally, restore the original flags
            for item, flags in itemFlags:
                item.setFlags(flags)
    

    Notes:

    1. the above code doesn't consider the possibility of trying to drag items that have different parent items (as explained in the comment); doing it is possible, but would require a much more complex implementation of both iterItems() and checking the parenthood of each item within the selection;
    2. drop from external sources is obviously not considered here;
    3. setDragDropMode(InternalMove) is still required; it can be set in Designer, anyway;