PyQt: Is it possible to drag/drop QWidgets in a QGridLayout to rearrange them?
Asked Answered
S

2

7

I am looking for a way to create a grid of graphs that can be dragged/dropped to rearrange the order. My first try was using QDockWidgets as they allow for drag/drop, however they were limited in a lot of other ways. Would it be possible to implement this function in a QGridLayout?

For now I have a QGridLayout with 3x3 matplotlib widgets.

Here is an example of the desired layout outcome.

enter image description here

Sample code:

import sys
from PyQt5 import QtWidgets
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg as FigureCanvas
import random

from PyQt5.QtWidgets import QGridLayout, QVBoxLayout, QHBoxLayout, QScrollArea, QWidget, QDialog, QApplication, QFrame

class IndicSelectWindow(QDialog):
    def __init__(self, parent=None):
        super(IndicSelectWindow, self).__init__(parent=parent)
        self.resize(1000, 800)

        self.layout = QtWidgets.QHBoxLayout(self)
        self.scrollArea = QScrollArea(self)
        self.scrollArea.setWidgetResizable(True)
        self.scrollAreaWidgetContents = QWidget()
        self.gridLayout = QGridLayout(self.scrollAreaWidgetContents)
        self.scrollArea.setWidget(self.scrollAreaWidgetContents)
        self.layout.addWidget(self.scrollArea)

        for i in range(3):
             for j in range(3):
                 self.Frame = QFrame(self)
                 self.Frame.setStyleSheet("background-color: white;")
                 self.Frame.setFrameStyle(QFrame.Panel | QFrame.Raised)
                 self.Frame.setLineWidth(2)
                 self.layout = QHBoxLayout(self.Frame)

                 self.figure = Figure()  # a figure to plot on
                 self.canvas = FigureCanvas(self.figure)
                 self.ax = self.figure.add_subplot(111)  # create an axis
                 data = [random.random() for i in range(10)]
                 self.ax.plot(data, '*-')  # plot data
                 self.canvas.draw()  # refresh canvas

                 self.layout.addWidget(self.canvas)

                 Box = QVBoxLayout()

                 Box.addWidget(self.Frame)

                 self.gridLayout.addLayout(Box, i, j)
                 self.gridLayout.setColumnStretch(i % 3, 1)
                 self.gridLayout.setRowStretch(j, 1)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    w = IndicSelectWindow()
    w.show()
    sys.exit(app.exec_())
Slr answered 31/3, 2020 at 7:6 Comment(0)
D
8

Here is an implementation that will swap the positions of the items involved in a drag/drop. The 3 main steps are:

(1) Reimplement mousePressEvent to get the index of the LayoutItem based on mouse coordinates.

(2) Reimplement mouseMoveEvent to set up a QDrag of the FigureCanvas.

(3) Reimplement dropEvent to swap the target items in the layout.

Since the matplotlib widgets absorb mouse events you also need to reimplement eventFilter to detect them.

import sys, random
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg as FigureCanvas

class IndicSelectWindow(QDialog):

    def __init__(self, parent=None):
        super(IndicSelectWindow, self).__init__(parent=parent)
        self.resize(1000, 800)

        self.target = None
        self.setAcceptDrops(True)
        self.layout = QHBoxLayout(self)
        self.scrollArea = QScrollArea(self)
        self.scrollArea.setWidgetResizable(True)
        self.scrollAreaWidgetContents = QWidget()
        self.gridLayout = QGridLayout(self.scrollAreaWidgetContents)
        self.scrollArea.setWidget(self.scrollAreaWidgetContents)
        self.layout.addWidget(self.scrollArea)

        for i in range(3):
            for j in range(3):
                self.Frame = QFrame(self)
                self.Frame.setStyleSheet("background-color: white;")
                self.Frame.setFrameStyle(QFrame.Panel | QFrame.Raised)
                self.Frame.setLineWidth(2)
                self.layout = QHBoxLayout(self.Frame)

                self.figure = Figure()  # a figure to plot on
                self.canvas = FigureCanvas(self.figure)
                self.ax = self.figure.add_subplot(111)  # create an axis
                data = [random.random() for i in range(10)]
                self.ax.plot(data, '*-')  # plot data
                self.canvas.draw()  # refresh canvas
                self.canvas.installEventFilter(self)

                self.layout.addWidget(self.canvas)

                Box = QVBoxLayout()

                Box.addWidget(self.Frame)

                self.gridLayout.addLayout(Box, i, j)
                self.gridLayout.setColumnStretch(i % 3, 1)
                self.gridLayout.setRowStretch(j, 1)

    def eventFilter(self, watched, event):
        if event.type() == QEvent.MouseButtonPress:
            self.mousePressEvent(event)
        elif event.type() == QEvent.MouseMove:
            self.mouseMoveEvent(event)
        elif event.type() == QEvent.MouseButtonRelease:
            self.mouseReleaseEvent(event)
        return super().eventFilter(watched, event)

    def get_index(self, pos):
        for i in range(self.gridLayout.count()):
            if self.gridLayout.itemAt(i).geometry().contains(pos) and i != self.target:
                return i

    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            self.target = self.get_index(event.windowPos().toPoint())
        else:
            self.target = None

    def mouseMoveEvent(self, event):
        if event.buttons() & Qt.LeftButton and self.target is not None:
            drag = QDrag(self.gridLayout.itemAt(self.target))
            pix = self.gridLayout.itemAt(self.target).itemAt(0).widget().grab()
            mimedata = QMimeData()
            mimedata.setImageData(pix)
            drag.setMimeData(mimedata)
            drag.setPixmap(pix)
            drag.setHotSpot(event.pos())
            drag.exec_()

    def mouseReleaseEvent(self, event):
        self.target = None

    def dragEnterEvent(self, event):
        if event.mimeData().hasImage():
            event.accept()
        else:
            event.ignore()

    def dropEvent(self, event):
        if not event.source().geometry().contains(event.pos()):
            source = self.get_index(event.pos())
            if source is None:
                return

            i, j = max(self.target, source), min(self.target, source)
            p1, p2 = self.gridLayout.getItemPosition(i), self.gridLayout.getItemPosition(j)

            self.gridLayout.addItem(self.gridLayout.takeAt(i), *p2)
            self.gridLayout.addItem(self.gridLayout.takeAt(j), *p1)


if __name__ == '__main__':
    app = QApplication(sys.argv)
    w = IndicSelectWindow()
    w.show()
    sys.exit(app.exec_())
Diagnostic answered 31/3, 2020 at 11:38 Comment(4)
Thanks @alec! Brilliant solution. Based on your code I will try to implement a way so the widgets push each other instead of swapping.Slr
@FrederikPetri Happy to help! I think it should be possible with setDropAction to Qt.MoveAction. And some concepts from the Flow Layout might be useful.Diagnostic
Thanks, I will check out the concepts you mentioned.Slr
@Diagnostic it would be good to add a drag.setPixmap() with the same image, so that the user knows that it's moving it. And, while you're at that, also a drag.setHotSpot(event.pos()) would be nice :-)Synoptic
S
0

I came across this as one of the only posts I could find for how to implement a click & drag to swap widgets around in a QGridLayout using python. @Alec's answer above helped me out a lot, but unfortunately it was designed for PyQt5, which is not forwards compatible with PyQt6.

I spent quite a while understanding then re-creating a working solution, and wanted to provide it to hopefully help anyone in the future who stumbles across this post.

One quick note: I had no need to utilize matplotlib in my project, and so to keep it simple, my answer focuses on moving around a basic QGroupBox widget in a QGridLayout using PyQt6. In theory, you could likely add a plot to the base movable widget you need to make, and adjust anything else here accordingly.

Steps:

  1. Create a simple widget object (see class MovableWidget) that's really just a standin for whatever more complex widget you intend to drag around.
  2. Implement a QMainWindow class, where we reimplement the eventFilter, mousePressEvent, mousePressEvent, mouseMoveEvent, and dropEvent, similar to @Alec's solution above.

Changes:

  1. I had to add an event trap (self.eventTrap), because for some reason, the mousePressEvent will trigger twice when you click on a widget to move it, which otherwise unsets the self.target variable. If anyone knows why that happens or has an improved solution I'll update this post accordingly.
  2. I do not have a layout wrapping around the widget object and instead move them between slots in the QGridLayout directly. I also removed a lot of the added complexity of the QScrollArea and extra QFrame object as I implemented my movable object as its own separate class.
from PyQt6.QtWidgets import (
    QWidget,QGridLayout,QVBoxLayout,QApplication,
    QPushButton,QMainWindow,QGroupBox,QLabel)
from PyQt6.QtCore import Qt, QEvent,QMimeData
from PyQt6.QtGui import (QMouseEvent, QDrag, QMouseEvent, QDragEnterEvent, 
    QDropEvent,QColor,QPalette,QFont)

class MovableWidget(QGroupBox):
    """Exteremely simple widget with a button and a label to move around"""
    def __init__(self,r,c,parent=None):
        super().__init__(parent=parent)
        self.setFixedSize(400,200) # So we have some area to grab on to
        # Store row/column for label
        self.r = r
        self.c = c
        
        # Setup a button for a basic QObject element to use
        self.btn = QPushButton(f"BUTTON: {r} {c}")
        self.btn.clicked.connect(self.btnClick)
        # Setup a Qlabel to show updated row/column
        self.lbl = QLabel(f"Current Loc: {r} {c}")

        # Set font larger
        fnt = QFont()
        fnt.setPointSize(15)
        self.btn.setFont(fnt)
        self.lbl.setFont(fnt)

        # Basic layout to setup widget
        tmpLayout = QVBoxLayout()
        tmpLayout.addWidget(self.btn)
        tmpLayout.addWidget(self.lbl)
        self.setLayout(tmpLayout)

        # Fill widget background with some color, so we can better see it.
        self.setAutoFillBackground(True)
        palette = self.palette()
        palette.setColor(QPalette.ColorRole.Window, QColor("#C5E0B4"))
        self.setPalette(palette)

    def btnClick(self,btn):
        """Display button text to console """
        sender:QPushButton = self.sender()
        textSender = sender.text()
        print(f"Button Pressed: [{textSender}]")
    def relabel(self,r,c):
        """Set button label text given row/col"""
        self.lbl.setText(f"Current Loc: {r} {c}")

class MyAppMainWin(QMainWindow):
    """ Basic Main Window, but with gridLayout configured to allow for movable objects """
    def __init__(self, parent=None):
        super().__init__()
        self.target = None
        self.setAcceptDrops(True)
        self.gridLayout = QGridLayout()

        for i in range(3):
            for j in range(3):
                myobj = MovableWidget(i,j)  # a widget to move around
                myobj.installEventFilter(self)

                self.gridLayout.addWidget(myobj, i, j)
                self.gridLayout.setColumnStretch(i % 3, 1)
                self.gridLayout.setRowStretch(j, 1)

        # Assign base widget w/ given layout as main/central widget
        self.baseMainWidget = QWidget()
        self.baseMainWidget.setLayout(self.gridLayout)
        self.setCentralWidget(self.baseMainWidget)

        # I was experiencing a double 'mousePressEvent', every time that I
        # clicked into a widget. The event trap holds the press event till
        # release or dropEvent
        self.eventTrap = None

    def eventFilter(self, watched, event:QEvent):
        if event.type() == QEvent.Type.MouseButtonPress:
            self.mousePressEvent(event)
        elif event.type() == QEvent.Type.MouseMove:
            self.mouseMoveEvent(event)
        elif event.type() == QEvent.Type.MouseButtonRelease:
            self.mouseReleaseEvent(event)
        return super().eventFilter(watched, event)

    def get_index(self, pos) -> int:
        """Helper Function = get widget index that we click into/drop into"""
        for i in range(self.gridLayout.count()):
            contains = self.gridLayout.itemAt(i).geometry().contains(pos)
            if contains and i != self.target:
                return i

    def mousePressEvent(self, event:QMouseEvent):
        if event.button() == Qt.MouseButton.LeftButton:
            if self.eventTrap is None:
                self.eventTrap = event
                self.target = self.get_index(event.scenePosition().toPoint())
            elif self.eventTrap == event:
                pass
            else:
                print("Something broke")
        else:
            self.target = None

    def mouseMoveEvent(self, event:QMouseEvent):
        if event.buttons() & Qt.MouseButton.LeftButton and self.target is not None:
            drag = QDrag(self.gridLayout.itemAt(self.target).widget())
            pix = self.gridLayout.itemAt(self.target).widget().grab()
            mimedata = QMimeData()
            mimedata.setImageData(pix)
            drag.setMimeData(mimedata)
            drag.setPixmap(pix)
            drag.setHotSpot(event.pos())
            drag.exec()

    def mouseReleaseEvent(self, event):
        # Reset our event trap & target handles. Note 'mouseReleaseEvent' is not called
        # if dropEvent happens, at least in my experience
        self.target = None
        self.eventTrap = None

    def dragEnterEvent(self, event:QDragEnterEvent):
        if event.mimeData().hasImage():
            event.accept()
        else:
            event.ignore()

    def dropEvent(self, event:QDropEvent):
        """Check if swap needs to occur and perform swap if so. """
        eventSource:QVBoxLayout = event.source() # For typehinting the next line
        if not eventSource.geometry().contains(event.position().toPoint()):
            source = self.get_index(event.position().toPoint())
            if source is None:
                self.eventTrap = None
                self.target = None
                return

            i, j = max(self.target, source), min(self.target, source)
            p1, p2 = self.gridLayout.getItemPosition(i), self.gridLayout.getItemPosition(j)

            # Update widget.lbl prior to moving items, while item handles are useful
            self.gridLayout.itemAt(i).widget().relabel(p2[0],p2[1])
            self.gridLayout.itemAt(j).widget().relabel(p1[0],p1[1])

            # The magic - pop item out of grid, then add item at new row/col/rowspan/colspan
            self.gridLayout.addItem(self.gridLayout.takeAt(i), *p2)
            self.gridLayout.addItem(self.gridLayout.takeAt(j), *p1)
            # Always reset our event trap & target handles.
            self.target = None
            self.eventTrap = None

if __name__ == '__main__':
    app = QApplication([])
    w = MyAppMainWin()
    w.show()
    app.exec()

I hope this helps anyone in the future looking to click & drag & drop widget objects around in python / PyQt6, and snap them into a QGridLayout. If anyone wishes to comment & correct me on something I'll update this accordingly. Thanks!

Snakebite answered 2/6, 2024 at 5:10 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.