Tuesday, January 28, 2014

Qt's Drag-and-Drop Architecture for Python and PyQt5
Pt. 9 Hacking QDrag

I began this project because I have an app that presents the user with a set of tabs (QTabSet). I have had user requests for the ability to pull some of these tabs out as separate windows, in the way that you can pull a tab out of a Firefox or Chrome browser window and it automatically expands to be an independent browser window.

How to do this in Qt5? Clearly, I thought, one starts with drag-and-drop. When the dragging cursor goes outside the bounds of the main window, end the drag and do the magic thing to move the widget that is now in a tab, to become a QDialog on its own.

All the prior descriptions are the result of my learning Qt drag-and-drop so I could misuse it in that way. And it appears so far that it can't be misused so, because when a drag ends the source widget doesn't get the info it needs. The source widget can tell that a drop succeeded with a target widget in the same Qt app. But it cannot tell the difference between a drop that didn't succeed and one that succeeded with a different app. And it particularly cannot tell where the cursor was, when the user released the button.

The Delayed-Encoding Hack

Drag support as delivered doesn't give that info, but could it be hacked to do so? Qt documents the Delayed Encoding Example. This code attempts to solve the problem where it is an expensive operation to encode the data into a MIME object, a cost that would be wasted if the drag did not complete.

The solution offered is to modify a QMimeData object. This object is not loaded with data, but is modified to issue a signal when its retrieveData() method is called. That call means that a drag target is trying to access the data, in other words, the drop has found a target that accepts it. Supposedly the signal is passed to a slot in the drag source widget, and it quickly does the data conversion and calls the setData() method of the modified QMimeData object in time for it to be retrieved.

Seems rather iffy and implementation-dependent to me. And indeed, there is a bug report saying it doesn't work in OS X because there, the data is pulled out of the drag as soon as the operation starts.

The Delayed Coding example suggests that drag-related objects can be modified, for example they can be made to issue signals when something happens. A signal can carry data, for example, the current value of QCursor.pos(), and then we would know where the mouse was at that time.

Instrumenting QDrag

The Delayed Coding example modifies QMimeData. Let's step back a bit and start with QDrag. I figured some of its methods would be called when a drop was starting. The following code is from hackdraggin.py.

class  DragOn(QDrag):
    def __init__(self, parent):
        super().__init__(parent)

    def source(self):
        print('Drag: source called')
        return super().source()

    def mimeData(self):
        print('Drag: mimeData called')
        return super().mimeData()

    def event(self, event_obj):
        print('Drag: event# ',int(event_obj.type()) )
        super().event(event_obj)

I used this class in place of QDrag in the SorcWidj code. Three methods are modified to print something when they are entered. source() is called directly from the doSomeDraggin() code, and that is the only one of these messages that prints!

DragOn class overrides the event() method. That would be called if any event, keyboard, mouse, whatever, was delivered to the drag object. None are, apparently, because that message never prints. Nor does the message from the mimeData() method, and that is very puzzling because if it isn't called, how does the QDropEvent get access to the passed data?

Thinking that perhaps a QDrag object was in some special purgatory where it couldn't get to stdout, I modified the class to store debugging data in the MIME object itself:

class  DragOn2(QDrag):
    def __init__(self, parent):
        super().__init__(parent)
        self.log_text = ''

    def setMimeData(self,md_object):
        self.md_obj = md_object # save ref. to QMimeData
        return super().setMimeData(md_object)

    def logSomething(self, text):
        self.log_text += text
        self.md_obj.setText(self.log_text)

    def mimeData(self):
        self.logSomething('mimeData called')
        return super().mimeData()

    def event(self, event_obj):
        self.logSomething( 'event {0} '.format(int(event_obj.type()) ) )
        super().event(event_obj)

The logSomething() method, if called, adds some text to a string and makes that string the payload of the MIME data. If either the event() or the mimeData() method is entered, there will be evidence in the text that is actually dropped.

Result? Nada. The dropped text is always the original text. logSomething() is not being called.

Conclusion? At least under PyQt5, the QDrag class is a dummy, a fake, nothing but a parameter list to the real code. The Qt code reaches in and gets the QMimeData object from it without going through its mimeData() method, and puts that into (presumably) the QDropEvent object. It is the latter that gets all the action; the QDrag object is inert.

Next up: Hack QMimeData

1 comment:

wrosecrans said...

I have been doing something similar with my own UI. I am using C++ rather than Python, but the concepts are the same. The one part that seems straightforward with the standard API seems to be tearing off a new window. Actually docking into an existing window is where I had most of my trouble. Anyway, here is how I am tearing off:

Qt::DropAction result = drag->exec(Qt::CopyAction | Qt::MoveAction, Qt::CopyAction);
if (result == 0)
{
DockContainer *dc = new DockContainer;
dc->move(QCursor::pos());
dc->addDockableTab(draggedDockable);
dc->resize(originalSize);
dc->show();
}

I am working under the theory that the mime type of my window drags is unique, so the only thing that should care about them is my app. (Have a unique per-process field in the MIME type if you can have multiple instances of the app open.) drag->exec() returns as soon as the drag is finished, so the location where the window was dropped is simply the current mouse position. Seems to work without any need to hack or subclass QDrag and company.