Monday, January 27, 2014

Qt's Drag-and-Drop Architecture for Python and PyQt5
Pt. 4, Coding a Drag Source

Code for a Drag Source

The whole code for the example program is at this pastebin link. Here we will look at it in pieces. Let's implement a drag-source.

class SorcWidj(QLabel):
    '''A simple drag-source with ability
    to recognize the start of a drag motion
    and implement the drag.'''
    def __init__(self,text):
        super().__init__()
        self.setText(text)
        self.mouse_down = False # has a left-click happened yet?
        self.mouse_posn = QPoint() # if so, this was where...
        self.mouse_time = QTime() # ...and this was when.

SorcWidj is just a QLabel with a few extra features, especially three members where we note the time and place of a click. We set these fields in the following method:

    def mousePressEvent(self,event):
        if event.button() == Qt.LeftButton :
            self.mouse_down = True # we are left-clicked-upon
            self.mouse_posn = event.pos() # here and...
            self.mouse_time.start() # ...now
        event.ignore()
        super().mousePressEvent(event) # pass it on up

When the user clicks down with any mouse button on this widget, mousePressEvent is entered. For this example we are only supporting left-clicks and left-drags. So, if this is a left-click, we save the position (x and y in local coordinates) and we start a millisecond timer going. Why do we want this info? Here's why:

    def mouseMoveEvent(self,event):
        if self.mouse_down :
            # Mouse left-clicked and is now moving. Is this the start of a
            # drag? Note time since the click and approximate distance moved
            # since the click and test against the app's standard.
            t = self.mouse_time.elapsed()
            d = (event.pos() - self.mouse_posn).manhattanLength()
            if t >= QApplication.startDragTime() \
            or d >= QApplication.startDragDistance() :
                # Yes, a proper drag is indicated. Commence dragging.
                self.doSomeDraggin(Qt.CopyAction|Qt.MoveAction)
                event.accept()
                return
        # Move does not (yet) constitute a drag, ignore it.
        event.ignore()
        super().mouseMoveEvent(event)

This logic is taken straight from the Qt documentation. Whenever the mouse moves above our widget with a button down, mouseMoveEvent() is called. If our mousePressEvent decided it was valid (in this case, if it was a left-click), we note the time t and distance d since that click event.

The application has a platform-dependent standard for the amount of time and distance that the mouse should move before the motion constitutes a "drag". We test against those standards. If they are met, then we initiate a drag, accept the event, and exit. Otherwise we pass the event along. Now, let's get to the beef. How do we initiate a drag?

    def doSomeDraggin(self, actions):
        # Create the QDrag object
        dragster = QDrag(self)
        # Make a scaled pixmap of our widget to put under the cursor.
        thumb = self.grab().scaledToHeight(50)
        dragster.setPixmap(thumb)
        dragster.setHotSpot(QPoint(thumb.width()/2,thumb.height()/2))
        # Create some data to be dragged and load it in the dragster.
        md = QMimeData()
        md.setText(self.text())
        dragster.setMimeData(md)
        # Initiate the drag, which really is a form of modal dialog.
        # Result is supposed to be the action performed at the drop.
        act = dragster.exec_(actions)
        defact = dragster.defaultAction()
        # Display the results of the drag.
        targ = dragster.target() # s.b. the widget that received the drop
        src = dragster.source() # s.b. this very widget
        print('exec returns',int(act),'default',int(defact),'target',type(targ), 'source',type(src))
        return

Once you have decided that a drag is necessary, this is how you initiate it. Let's go over it in pieces.

        dragster = QDrag(self)

The QDrag object represents the drag. We will initialize it and then execute it much as we execute a modal dialog.

        thumb = self.grab().scaledToHeight(50)
        dragster.setPixmap(thumb)

The QWidget.grab() method was added in Qt5. It returns a pixmap of that widget as it looks now. Here we grab a pixmap of our own widget (we take a selfie!). And scale it to be no more than 50px high. We apply our selfie pixmap to the drag object. It will be displayed under the cursor and follow it around during the drag. The pixmap is optional; in your application you might not use it, or you might use a pixmap of something else.

        dragster.setHotSpot(QPoint(thumb.width()/2,thumb.height()/2))

Another optional step repositions the selfie thumbnail so that it is centered under the cursor. Without this, cursor will be at the top left corner of the thumbnail pixmap.

        md = QMimeData()
        md.setText(self.text())
        dragster.setMimeData(md)

This is the heart of drag initiation. You are supposed to package the data that is being dragged in the form of MIME data. MIME began as a standard for attaching arbitrary data to emails. It has been extended to allow passing data between any programs.

In principle you can package just about anything as MIME data. You load the QMimeData object with the data and set it to have the appropriate MIME type. Then you assign it to the drag object.

Why do this? Because you don't know where the drag is going. It isn't necessarily going to some other part of your app. It might be dropped anywhere, on any app, or on the desktop. By packaging the data as a MIME type, you ensure that any other application that supports MIME can accept it.

In this example, we are punting the whole issue and setting the MIME data to the current text of this QLabel. If your application has to pass something more structured than simple text, you will have to study the QMimeData reference and the Qt page on MIME data.

        act = dragster.exec_(actions)

This statement initiates the drag operation. Just as with a modal dialog, you exec_() the drag object. The argument is the set of actions you will permit the drop to perform: some OR-combination of Qt.MoveAction, Qt.CopyAction, and Qt.LinkAction. (We passed these from the mouseMoveEvent() code.)

Once the drag starts, this code is effectively suspended until the user lets go of the mouse. In Linux and Mac OS, signals continue to be processed and other threads of the app keep executing. In Windows, the whole app stops.

Eventually the user will relax her finger on the mouse and end the drag. Then, in theory, the action code that was actually performed is returned. The statements that follow in our example print out what can be learned after the drag completes: the supposed action, the default action, and the identities of the source widget (this one) and the target widget that accepted the drag.

If and only if the drop is accepted by a Qt widget in this same application, the returned action will be one of Qt.MoveAction, Qt.CopyAction, or Qt.LinkAction. And the widget returned by the target() method of the drag object will be a reference to the widget that accepted the drop.

If the drop completes in some other application, whether written in Qt or not, the returned action will be 0, and the value returned by dragster.target() will be None. Those things will also be the case if the drop simply doesn't complete, for example if the user releases the mouse over some location that doesn't accept drops.

This is a bit of a hole in the Qt drag-and-drop support. There is no standard way to tell if a drag completed successfully in another app's window, or just didn't complete.

No comments: