Search code examples
pythonpyqtdrag-and-dropqlistwidgetqheaderview

Drag and drop columns between QHeaderView and QListWidget


I am having troubled using the QHeaderView drag & drop feature. When I subclass a QHeaderView, I am able to accept drops with no issue. However, when I click on the QHeaderView and try to drag from one of the columns, nothing appears to happen.

Below I have re-implemented several drag events to simply print if they were called. However, only the dragEnterEvent is successful. No other event such as startDrag is ever called. My ultimate goal is to have a QTableView where I can drag columns from and to a QListWidget (essentially hiding the column) and the user can then drag the QListWidget item back onto the QTableView if they want the column and its data to be visible again. However, I can’t move forward until I can understand why the QHeaderView is not allowing me to drag. Any help would be greatly appreciated.

class MyHeader(QHeaderView):
    def __init__(self, parent=None):
        super().__init__(Qt.Horizontal, parent)
        self.setDragEnabled(True)
        self.setAcceptDrops(True)

    def startDrag(self, *args, **kwargs):
        print('start drag success')

    def dragEnterEvent(self, event):
        print('drag enter success')

    def dragLeaveEvent(self, event):
        print('drag leave success')

    def dragMoveEvent(self, event):
        print('drag move success')

class Form(QDialog):
    def __init__(self, parent=None):
        super().__init__(parent)

        listWidget = QListWidget()
        listWidget.setDragEnabled(True)
        listWidget.setAcceptDrops(True)
        listWidget.addItem('item #1')
        listWidget.addItem('item #2')

        tableWidget = QTableWidget()
        header = MyHeader()
        tableWidget.setHorizontalHeader(header)
        tableWidget.setRowCount(5)
        tableWidget.setColumnCount(2)
        tableWidget.setHorizontalHeaderLabels(["Column 1", "Column 2"])

        splitter = QSplitter(Qt.Horizontal)
        splitter.addWidget(listWidget)
        splitter.addWidget(tableWidget)
        layout = QHBoxLayout()
        layout.addWidget(splitter)
        self.setLayout(layout)

if __name__=='__main__':
    import sys
    app = QApplication(sys.argv)
    form= Form()
    form.show()
    sys.exit(app.exec_())

Solution

  • The QHeaderView class does not use the drag and drop methods inherited from QAbstractItemView, because it never needs to initiate a drag operation. Drag and drop is only used for rearranging columns, and it is not necessary to use the QDrag mechanism for that.

    Given this, it will be necessary to implement custom drag and drop handling (using mousePressEvent, mouseMoveEvent and dropEvent), and also provide functions for encoding and decoding the mime-data format that Qt uses to pass items between views. An event-filter will be needed for the table-widget, so that dropping is still possible when all columns are hidden; and also for the list-widget, to stop it copying items to itself.

    The demo script below implements all of that. There are probably some more refinements needed, but it should be enough to get you started:

    import sys
    from PyQt5.QtCore import *
    from PyQt5.QtGui import *
    from PyQt5.QtWidgets import *
    
    class MyHeader(QHeaderView):
        MimeType = 'application/x-qabstractitemmodeldatalist'
        columnsChanged = pyqtSignal(int)
    
        def __init__(self, parent=None):
            super().__init__(Qt.Horizontal, parent)
            self.setDragEnabled(True)
            self.setAcceptDrops(True)
            self._dragstartpos = None
    
        def encodeMimeData(self, items):
            data = QByteArray()
            stream = QDataStream(data, QIODevice.WriteOnly)
            for column, label in items:
                stream.writeInt32(0)
                stream.writeInt32(column)
                stream.writeInt32(2)
                stream.writeInt32(int(Qt.DisplayRole))
                stream.writeQVariant(label)
                stream.writeInt32(int(Qt.UserRole))
                stream.writeQVariant(column)
            mimedata = QMimeData()
            mimedata.setData(MyHeader.MimeType, data)
            return mimedata
    
        def decodeMimeData(self, mimedata):
            data = []
            stream = QDataStream(mimedata.data(MyHeader.MimeType))
            while not stream.atEnd():
                row = stream.readInt32()
                column = stream.readInt32()
                item = {}
                for count in range(stream.readInt32()):
                    key = stream.readInt32()
                    item[key] = stream.readQVariant()
                data.append([item[Qt.UserRole], item[Qt.DisplayRole]])
            return data
    
        def mousePressEvent(self, event):
            if event.button() == Qt.LeftButton:
                self._dragstartpos = event.pos()
            super().mousePressEvent(event)
    
        def mouseMoveEvent(self, event):
            if (event.buttons() & Qt.LeftButton and
                self._dragstartpos is not None and
                (event.pos() - self._dragstartpos).manhattanLength() >=
                QApplication.startDragDistance()):
                column = self.logicalIndexAt(self._dragstartpos)
                data = [column, self.model().headerData(column, Qt.Horizontal)]
                self._dragstartpos = None
                drag = QDrag(self)
                drag.setMimeData(self.encodeMimeData([data]))
                action = drag.exec(Qt.MoveAction)
                if action != Qt.IgnoreAction:
                    self.setColumnHidden(column, True)
    
        def dropEvent(self, event):
            mimedata = event.mimeData()
            if mimedata.hasFormat(MyHeader.MimeType):
                if event.source() is not self:
                    for column, label in self.decodeMimeData(mimedata):
                        self.setColumnHidden(column, False)
                    event.setDropAction(Qt.MoveAction)
                    event.accept()
                else:
                    event.ignore()
            else:
                super().dropEvent(event)
    
        def setColumnHidden(self, column, hide=True):
            count = self.count()
            if 0 <= column < count and hide != self.isSectionHidden(column):
                if hide:
                    self.hideSection(column)
                else:
                    self.showSection(column)
                self.columnsChanged.emit(count - self.hiddenSectionCount())
    
    class Form(QDialog):
        def __init__(self, parent=None):
            super().__init__(parent)
    
            self.listWidget = QListWidget()
            self.listWidget.setAcceptDrops(True)
            self.listWidget.setDragEnabled(True)
            self.listWidget.viewport().installEventFilter(self)
    
            self.tableWidget = QTableWidget()
            header = MyHeader(self)
            self.tableWidget.setHorizontalHeader(header)
            self.tableWidget.setRowCount(5)
            self.tableWidget.setColumnCount(4)
    
            labels = ["Column 1", "Column 2", "Column 3", "Column 4"]
            self.tableWidget.setHorizontalHeaderLabels(labels)
            for column, label in enumerate(labels):
                if column > 1:
                    item = QListWidgetItem(label)
                    item.setData(Qt.UserRole, column)
                    self.listWidget.addItem(item)
                    header.hideSection(column)
    
            header.columnsChanged.connect(
                lambda count: self.tableWidget.setAcceptDrops(not count))
            self.tableWidget.viewport().installEventFilter(self)
    
            splitter = QSplitter(Qt.Horizontal)
            splitter.addWidget(self.listWidget)
            splitter.addWidget(self.tableWidget)
            layout = QHBoxLayout()
            layout.addWidget(splitter)
            self.setLayout(layout)
    
        def eventFilter(self, source, event):
            if event.type() == QEvent.Drop:
                if source is self.tableWidget.viewport():
                    self.tableWidget.horizontalHeader().dropEvent(event)
                    return True
                else:
                    event.setDropAction(Qt.MoveAction)
            return super().eventFilter(source, event)
    
    if __name__=='__main__':
    
        app = QApplication(sys.argv)
        form = Form()
        form.setGeometry(600, 50, 600, 200)
        form.show()
        sys.exit(app.exec_())