Drag and drop rows within QTableWidget
Asked Answered
S

7

15

Goal

My goal is to have a QTableWidget in which the user can drag/drop rows internally. That is, the user can drag and drop one entire row, moving it up or down in the table to a different location in between two other rows. The goal is illustrated in this figure:

the challenge

What I tried, and what happens

Once I have populated a QTableWidget with data, I set its properties as follows:

table.setDragDropMode(QtGui.QAbstractItemView.InternalMove)   
#select one row at a time
table.setSelectionMode(QtGui.QAbstractItemView.SingleSelection) 
table.setSelectionBehavior(QtGui.QAbstractItemView.SelectRows)

Similar code makes QListWidget behave nicely: when you move an item internally, it is dropped between two elements of the list, and the rest of the items sort themselves out in a reasonable way, with no data overwritten (in other words, the view acts like the figure above, but it is a list).

In contrast, in a table modified with the code above, things don't work out as planned. The following figure shows what actually happens:

crud

In words: when row i is dropped, that row becomes blank in the table. Further, if I accidentally drop row i onto row j (instead of the space between two rows), the data from row i replaces the data in row j. That is, in that unfortunate case, in addition to row i becoming blank, row j is overwritten.

Note I also tried adding table.setDragDropOverwriteMode(False) but it didn't change the behavior.

A way forward?

This bug report might include a possible solution in C++: it seems they reimplemented dropEvent for QTableWidget, but I am not sure how to cleanly port to Python.

Related content:

Sopping answered 7/10, 2014 at 2:54 Comment(1)
qt-project.org is dead, bug now at bugreports.qt.io/browse/QTBUG-13873Utile
S
15

This seems very bizarre default behaviour. Anyway, following the code in the bug report you linked to, I have successfully ported something to PyQt. It may, or may not be as robust as that code, but it at least seems to work for the simple test case you provide in your screenshots!

The potential issues with the below implementation are:

  • The currently selected row doesn't follow the drag and drop (so if you move the third row, the third row stays selected after the move). This probably isn't too hard to fix!

  • It might not work for rows with child rows. I'm not even sure if a QTableWidgetItem can have children, so maybe it is fine.

  • I haven't tested with selecting multiple rows, but I think it should work

  • For some reason I didn't have to remove the row that was being moved, despite inserting a new row into the table. This seems very odd to me. It almost appears like inserting a row anywhere but the end does not increase the rowCount() of the table.

  • My implementation of GetSelectedRowsFast is a bit different to theirs. It may not be fast, and could potentially have some bugs in it (I don't check if the items are enabled or selectable) like they did. This would also be easy to fix I think, but is only a problem if you disable a row while it is selected and someone then performs a drag/drop. In this situation, I think the better solution might be to unselect rows as they were disabled, but it depends on what you are doing with it I guess!

If you were using this code in a production environment, you would probably want to go over it with a fine-tooth-comb and make sure everything made sense. There are quite probably issues with my PyQt port, and possibly issues with the original c++ algorithm my port was based on. It does however serve as a proof that what you want can be achieved using a QTableWidget.

Update: note there is an additional answer below for PyQt5 that also fixes some of the concerns I had above. You might want to check it out!

Code:

import sys, os
from PyQt4.QtCore import *
from PyQt4.QtGui import *

class TableWidgetDragRows(QTableWidget):
    def __init__(self, *args, **kwargs):
        QTableWidget.__init__(self, *args, **kwargs)

        self.setDragEnabled(True)
        self.setAcceptDrops(True)
        self.viewport().setAcceptDrops(True)
        self.setDragDropOverwriteMode(False)
        self.setDropIndicatorShown(True)

        self.setSelectionMode(QAbstractItemView.SingleSelection) 
        self.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.setDragDropMode(QAbstractItemView.InternalMove)   

    def dropEvent(self, event):
        if event.source() == self and (event.dropAction() == Qt.MoveAction or self.dragDropMode() == QAbstractItemView.InternalMove):
            success, row, col, topIndex = self.dropOn(event)
            if success:             
                selRows = self.getSelectedRowsFast()                        

                top = selRows[0]
                # print 'top is %d'%top
                dropRow = row
                if dropRow == -1:
                    dropRow = self.rowCount()
                # print 'dropRow is %d'%dropRow
                offset = dropRow - top
                # print 'offset is %d'%offset

                for i, row in enumerate(selRows):
                    r = row + offset
                    if r > self.rowCount() or r < 0:
                        r = 0
                    self.insertRow(r)
                    # print 'inserting row at %d'%r


                selRows = self.getSelectedRowsFast()
                # print 'selected rows: %s'%selRows

                top = selRows[0]
                # print 'top is %d'%top
                offset = dropRow - top                
                # print 'offset is %d'%offset
                for i, row in enumerate(selRows):
                    r = row + offset
                    if r > self.rowCount() or r < 0:
                        r = 0

                    for j in range(self.columnCount()):
                        # print 'source is (%d, %d)'%(row, j)
                        # print 'item text: %s'%self.item(row,j).text()
                        source = QTableWidgetItem(self.item(row, j))
                        # print 'dest is (%d, %d)'%(r,j)
                        self.setItem(r, j, source)

                # Why does this NOT need to be here?
                # for row in reversed(selRows):
                    # self.removeRow(row)

                event.accept()

        else:
            QTableView.dropEvent(event)                

    def getSelectedRowsFast(self):
        selRows = []
        for item in self.selectedItems():
            if item.row() not in selRows:
                selRows.append(item.row())
        return selRows

    def droppingOnItself(self, event, index):
        dropAction = event.dropAction()

        if self.dragDropMode() == QAbstractItemView.InternalMove:
            dropAction = Qt.MoveAction

        if event.source() == self and event.possibleActions() & Qt.MoveAction and dropAction == Qt.MoveAction:
            selectedIndexes = self.selectedIndexes()
            child = index
            while child.isValid() and child != self.rootIndex():
                if child in selectedIndexes:
                    return True
                child = child.parent()

        return False

    def dropOn(self, event):
        if event.isAccepted():
            return False, None, None, None

        index = QModelIndex()
        row = -1
        col = -1

        if self.viewport().rect().contains(event.pos()):
            index = self.indexAt(event.pos())
            if not index.isValid() or not self.visualRect(index).contains(event.pos()):
                index = self.rootIndex()

        if self.model().supportedDropActions() & event.dropAction():
            if index != self.rootIndex():
                dropIndicatorPosition = self.position(event.pos(), self.visualRect(index), index)

                if dropIndicatorPosition == QAbstractItemView.AboveItem:
                    row = index.row()
                    col = index.column()
                    # index = index.parent()
                elif dropIndicatorPosition == QAbstractItemView.BelowItem:
                    row = index.row() + 1
                    col = index.column()
                    # index = index.parent()
                else:
                    row = index.row()
                    col = index.column()

            if not self.droppingOnItself(event, index):
                # print 'row is %d'%row
                # print 'col is %d'%col
                return True, row, col, index

        return False, None, None, None

    def position(self, pos, rect, index):
        r = QAbstractItemView.OnViewport
        margin = 2
        if pos.y() - rect.top() < margin:
            r = QAbstractItemView.AboveItem
        elif rect.bottom() - pos.y() < margin:
            r = QAbstractItemView.BelowItem 
        elif rect.contains(pos, True):
            r = QAbstractItemView.OnItem

        if r == QAbstractItemView.OnItem and not (self.model().flags(index) & Qt.ItemIsDropEnabled):
            r = QAbstractItemView.AboveItem if pos.y() < rect.center().y() else QAbstractItemView.BelowItem

        return r


class Window(QWidget):
    def __init__(self):
        super(Window, self).__init__()

        layout = QHBoxLayout()
        self.setLayout(layout) 

        self.table_widget = TableWidgetDragRows()
        layout.addWidget(self.table_widget) 

        # setup table widget
        self.table_widget.setColumnCount(2)
        self.table_widget.setHorizontalHeaderLabels(['Colour', 'Model'])

        items = [('Red', 'Toyota'), ('Blue', 'RV'), ('Green', 'Beetle')]
        for i, (colour, model) in enumerate(items):
            c = QTableWidgetItem(colour)
            m = QTableWidgetItem(model)

            self.table_widget.insertRow(self.table_widget.rowCount())
            self.table_widget.setItem(i, 0, c)
            self.table_widget.setItem(i, 1, m)

        self.show()


app = QApplication(sys.argv)
window = Window()
sys.exit(app.exec_())
Sentinel answered 11/10, 2014 at 4:48 Comment(2)
great stuff, certainly learning a lot from this one nice example! I have written a follow-up to help me try to understand supportedDropActions: https://mcmap.net/q/822316/-access-values-of-dropactions-in-model-pyside-pyqt-qt.Sopping
thanks a lot. your link to the qt bugreport site was all what i searched for. Needed it for c++ so unfortunatly your code was not exactly what i needed :)Alamein
C
12

Here is a revised version of three-pineapples answer that is designed for PyQt5 and Python 3. It also fixes multi-select drag-and-drop and reselects the rows after the move.

import sys

from PyQt5.QtCore import Qt
from PyQt5.QtGui import QDropEvent
from PyQt5.QtWidgets import QTableWidget, QAbstractItemView, QTableWidgetItem, QWidget, QHBoxLayout, \
    QApplication


class TableWidgetDragRows(QTableWidget):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.setDragEnabled(True)
        self.setAcceptDrops(True)
        self.viewport().setAcceptDrops(True)
        self.setDragDropOverwriteMode(False)
        self.setDropIndicatorShown(True)

        self.setSelectionMode(QAbstractItemView.ExtendedSelection)
        self.setSelectionBehavior(QAbstractItemView.SelectRows)
        self.setDragDropMode(QAbstractItemView.InternalMove)

    def dropEvent(self, event: QDropEvent):
        if not event.isAccepted() and event.source() == self:
            drop_row = self.drop_on(event)

            rows = sorted(set(item.row() for item in self.selectedItems()))
            rows_to_move = [[QTableWidgetItem(self.item(row_index, column_index)) for column_index in range(self.columnCount())]
                            for row_index in rows]
            for row_index in reversed(rows):
                self.removeRow(row_index)
                if row_index < drop_row:
                    drop_row -= 1

            for row_index, data in enumerate(rows_to_move):
                row_index += drop_row
                self.insertRow(row_index)
                for column_index, column_data in enumerate(data):
                    self.setItem(row_index, column_index, column_data)
            event.accept()
            for row_index in range(len(rows_to_move)):
                self.item(drop_row + row_index, 0).setSelected(True)
                self.item(drop_row + row_index, 1).setSelected(True)
        super().dropEvent(event)

    def drop_on(self, event):
        index = self.indexAt(event.pos())
        if not index.isValid():
            return self.rowCount()

        return index.row() + 1 if self.is_below(event.pos(), index) else index.row()

    def is_below(self, pos, index):
        rect = self.visualRect(index)
        margin = 2
        if pos.y() - rect.top() < margin:
            return False
        elif rect.bottom() - pos.y() < margin:
            return True
        # noinspection PyTypeChecker
        return rect.contains(pos, True) and not (int(self.model().flags(index)) & Qt.ItemIsDropEnabled) and pos.y() >= rect.center().y()


class Window(QWidget):
    def __init__(self):
        super(Window, self).__init__()

        layout = QHBoxLayout()
        self.setLayout(layout)

        self.table_widget = TableWidgetDragRows()
        layout.addWidget(self.table_widget) 

        # setup table widget
        self.table_widget.setColumnCount(2)
        self.table_widget.setHorizontalHeaderLabels(['Type', 'Name'])

        items = [('Red', 'Toyota'), ('Blue', 'RV'), ('Green', 'Beetle'), ('Silver', 'Chevy'), ('Black', 'BMW')]
        self.table_widget.setRowCount(len(items))
        for i, (color, model) in enumerate(items):
            self.table_widget.setItem(i, 0, QTableWidgetItem(color))
            self.table_widget.setItem(i, 1, QTableWidgetItem(model))

        self.resize(400, 400)
        self.show()


if __name__ == '__main__':
    app = QApplication(sys.argv)
    window = Window()
    sys.exit(app.exec_())
Cambrel answered 4/5, 2017 at 17:29 Comment(2)
Any solution for QTreeWidget with multi-column multi-items and multi-child,dragdrop one line child changing from one item to another item or position.@scott-maxwellAntineutron
why? when cells contains images or widgets it just vanishes after moving any row ?Glennisglennon
V
5

So I came across this same issue recently and I distilled the above code block down into something that I think has all the same behavior, but is much more succinct.

def dropEvent(self, event):
  if event.source() == self:
      rows = set([mi.row() for mi in self.selectedIndexes()])
      targetRow = self.indexAt(event.pos()).row()
      rows.discard(targetRow)
      rows = sorted(rows)
      if not rows:
          return
      if targetRow == -1:
          targetRow = self.rowCount()
      for _ in range(len(rows)):
          self.insertRow(targetRow)
      rowMapping = dict() # Src row to target row.
      for idx, row in enumerate(rows):
          if row < targetRow:
              rowMapping[row] = targetRow + idx
          else:
              rowMapping[row + len(rows)] = targetRow + idx
      colCount = self.columnCount()
      for srcRow, tgtRow in sorted(rowMapping.iteritems()):
          for col in range(0, colCount):
              self.setItem(tgtRow, col, self.takeItem(srcRow, col))
      for row in reversed(sorted(rowMapping.iterkeys())):
          self.removeRow(row)
      event.accept()
      return
Villanelle answered 13/6, 2016 at 22:35 Comment(1)
Not close to my computer to check this out, but definitely will, and will let you know how it works.Sopping
T
2

Since I did not find any proper solution for using C++ using google i want to add mine:

#include "mytablewidget.h"

MyTableWidget::MyTableWidget(QWidget *parent) : QTableWidget(parent)
{

}

void MyTableWidget::dropEvent(QDropEvent *event)
{
    if(event->source() == this)
    {
        int newRow = this->indexAt(event->pos()).row();
        QTableWidgetItem *selectedItem;
        QList<QTableWidgetItem*> selectedItems = this->selectedItems();
        if(newRow == -1)
            newRow = this->rowCount();
        int i;
        for(i = 0; i < selectedItems.length()/this->columnCount(); i++)
        {
            this->insertRow(newRow);
        }
        int currentOldRow = -1;
        int currentNewRow = newRow-1;
        QList<int> deleteRows;
        foreach(selectedItem, selectedItems)
        {
            int column = selectedItem->column();
            if(selectedItem->row() != currentOldRow)
            {
                currentOldRow = selectedItem->row();
                deleteRows.append(currentOldRow);
                currentNewRow++;
            }
            this->takeItem(currentOldRow, column);
            this->setItem(currentNewRow, column, selectedItem);
        }

        for(i = deleteRows.count()-1; i>=0; i--)
        {
            this->removeRow(deleteRows.at(i));
        }
    }
}
Tubby answered 17/12, 2016 at 22:20 Comment(1)
I would add a this->setCellWidget(currentNewRow, column, this->cellWidget(currentOldRow, column)); right after this->setItem(currentNewRow, column, selectedItem); in order to handle cells with widgets properly as well.Holophytic
B
2

Based on the previous answers, here's an updated code for Qt6:

import sys

from PyQt6.QtWidgets import (
    QTableWidget,
    QAbstractItemView,
    QTableWidgetItem,
    QWidget,
    QHBoxLayout,
    QApplication,
)


class TableWidgetDragRows(QTableWidget):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.setDragEnabled(True)
        self.setAcceptDrops(True)
        self.viewport().setAcceptDrops(True)
        self.setDragDropOverwriteMode(False)
        self.setDropIndicatorShown(True)

        self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
        self.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)

    def dropEvent(self, event):
        if event.source() == self:
            rows = set([mi.row() for mi in self.selectedIndexes()])
            targetRow = self.indexAt(event.position().toPoint()).row()
            rows.discard(targetRow)
            rows = sorted(rows)
            if not rows:
                return
            if targetRow == -1:
                targetRow = self.rowCount()
            for _ in range(len(rows)):
                self.insertRow(targetRow)
            rowMapping = dict()  # Src row to target row.
            for idx, row in enumerate(rows):
                if row < targetRow:
                    rowMapping[row] = targetRow + idx
                else:
                    rowMapping[row + len(rows)] = targetRow + idx
            colCount = self.columnCount()
            for srcRow, tgtRow in sorted(rowMapping.items()):
                for col in range(0, colCount):
                    self.setItem(tgtRow, col, self.takeItem(srcRow, col))
            for row in reversed(sorted(rowMapping.keys())):
                self.removeRow(row)
            event.accept()
            return


class Window(QWidget):
    def __init__(self):
        super(Window, self).__init__()

        layout = QHBoxLayout()
        self.setLayout(layout)

        self.table_widget = TableWidgetDragRows()
        layout.addWidget(self.table_widget)

        # setup table widget
        self.table_widget.setColumnCount(2)
        self.table_widget.setHorizontalHeaderLabels(["Type", "Name"])

        items = [
            ("Red", "Toyota"),
            ("Blue", "RV"),
            ("Green", "Beetle"),
            ("Silver", "Chevy"),
            ("Black", "BMW"),
        ]
        self.table_widget.setRowCount(len(items))
        for i, (color, model) in enumerate(items):
            self.table_widget.setItem(i, 0, QTableWidgetItem(color))
            self.table_widget.setItem(i, 1, QTableWidgetItem(model))

        self.resize(400, 400)
        self.show()


if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = Window()
    sys.exit(app.exec())
Blackandblue answered 7/4, 2023 at 22:11 Comment(0)
P
1

Even if older task, as it took me a while to find out how it works in case the rows contains QTableWidgetItem and Widgets set by setCellWidget ...

Maybe it help others too when searching for this issue.

Problem is, that with the solutions above, the text provided within a QTableWidgetItem is moving well, however widgets like icons or buttons disappear after move.

A first idea might be to catch the widget inside via the cellWidget() method and then set it back via setCellWidget(), however this fails as the QTableWidget allows access to a widget via the cellWidget() method, but does not return the widget object itself. Anyhow, doing so will let the Python app crash ...

The only possibility is (as far as I had found out) to create a callback to your parent and create the widget again.

Due to, my dropEvent method in my MyTableWidget class looks like:

    def dropEvent(self, event):

    if not event.isAccepted() and event.source() == self:
        drop_row = self.drop_on(event)

        rows = sorted(set(item.row() for item in self.selectedItems()))

        rows_to_move = []
        for row_index in rows:
            items = dict()
            for column_index in range(self.columnCount()):
                # get the widget or item of current cell
                widget = self.cellWidget(row_index, column_index)
                if isinstance(widget, type(None)):
                    # if widget is NoneType, it is a QTableWidgetItem
                    items[column_index] = {"kind": "QTableWidgetItem",
                                           "item": QTableWidgetItem(self.item(row_index, column_index))}
                else:
                    # otherwise it is any other kind of widget. So we catch the widgets unique (hopefully) objectname
                    items[column_index] = {"kind": "QWidget",
                                           "item": widget.objectName()}

            rows_to_move.append(items)

        for row_index in reversed(rows):
            self.removeRow(row_index)
            if row_index < drop_row:
                drop_row -= 1

        for row_index, data in enumerate(rows_to_move):
            row_index += drop_row
            self.insertRow(row_index)

            for column_index, column_data in data.items():
                if column_data["kind"] == "QTableWidgetItem":
                    # for QTableWidgetItem we can re-create the item directly
                    self.setItem(row_index, column_index, column_data["item"])
                else:
                    # for others we call the parents callback function to get the widget
                    _widget = self._parent.get_table_widget(column_data["item"])
                    if _widget is not None:
                        self.setCellWidget(row_index, column_index, _widget)

        event.accept()

    super().dropEvent(event)

To do so, you have to pass your calling parent into your MyTableWidget class, and you need a callback function ("get_table_widget" in my code above) which provide the widget object related to the given input (in my case the object name which is enough for me).

Panto answered 26/1, 2022 at 11:54 Comment(0)
R
1

I just implemented this behavior in Qt6 itself. It will likely be in Qt 6.9.

All you'll have to do is call setDragDropOverwriteMode(false) on the QTableWidget (or QTableView) and adjust the flags so that ItemIsDropEnabled isn't set on items. As a benefit compared to most implementations found here, it also takes care of moving vertical header items, and selects the destination row, because it's implemented as a move rather than insert+delete.

Report answered 12/8 at 18:9 Comment(2)
So it just works like it is supposed to now? :)Sopping
Yes, it works as in the first drawing in the original question.Report

© 2022 - 2024 — McMap. All rights reserved.