Thursday, January 30, 2014

Wednesday, January 29, 2014

Qt's Drag-and-Drop Architecture for Python and PyQt5
A Doh! Moment

Sometimes you have to take the long way around to the obvious answer...

So I am concerned about knowing where the mouse is, when a drag action ends. I even hinted I could use a signal to send back the value of QCursor.pos() from inside some drag object.

Then it occurred to me: if I want the cursor position at the end of a drag, why don't I just, oh, I don't know, sample the cursor position at the end of the drag?

In other words, change the doSomeDraggin() routine like this:

        act = dragster.exec_(actions)
        global_pos = QCursor.pos() # cursor immediately after drag ends
        local_pos = self.mapFromGlobal(global_pos)
        print('cursor at local  {0}, {1}'.format(local_pos.x(),local_pos.y()))
        if not self.parentWidget().rect().contains(local_pos) :
            print('drop was outside my parent')

And of course this works fine. Even on a slow machine, the mouse can't have moved more than a pixel or two between release of the mouse button and the return from the exec_() of the drag.

Tuesday, January 28, 2014

Qt's Drag-and-Drop Architecture for Python and PyQt5
Pt. 10 Hacking QMimeData

Modifying QDrag is no use. What about QMimeData, which is key to the Delayed Encoding hack? I set up the following modified MIME data class.

class MaimData(QMimeData):
    def __init__(self):
        super().__init__()
    def retrieveData(self,mt,ty):
        print('MT retrieveData')
        return super().retrieveData(mt,ty)
    def formats(self):
        print('MT formats')
        return super().formats()

The results were very interesting (on Mac OS X 10.9, this is).

starting drag with actions: Copy Move Link
MT formats
MT retrieveData
MT retrieveData
MT retrieveData
MT retrieveData
MT retrieveData
drag enters at 0 20 kbd mods 0 buttons 1 offering actions: Copy Move Link
MT formats
target moved to <class '__main__.TargWidj'>
drag moving at 1 20
drag moving at 3 19
drag moving at 4 19
drag moving at 5 19
dropping at 5 19 actions: Move
 -- setting copy action!
MT formats
MT retrieveData
exec returns 1 default 2 target <class '__main__.TargWidj'> source <class '__main__.SorcWidj'>

There is an immediate call to formats() and then five (5!) successive calls to retrieveData(). These all take place the moment the drag begins, while the mouse has barely moved. This immediately shows why the Delayed Encoding hack fails on Mac OS: not only would the expensive data conversion not be delayed; it would be done multiple times!

The next call to formats happens when the dragEnterEvent() method of the target widget accesses event.mimeData().hasText(). Then both methods are called during drop event processing.

Well, this looks promising. What happens if the drag is dropped onto a different application?

starting drag with actions: Copy Move Link
MT formats
MT retrieveData
MT retrieveData
MT retrieveData
MT retrieveData
MT retrieveData
exec returns 0 default 2 target <class 'NoneType'> source <class '__main__.SorcWidj'>

OK, what happens if it is dropped on the desktop?

starting drag with actions: Copy Move Link
MT formats
MT retrieveData
MT retrieveData
MT retrieveData
MT retrieveData
MT retrieveData
exec returns 0 default 2 target <class 'NoneType'> source <class '__main__.SorcWidj'>

Oops. What about a drop that fails, releasing the mouse over an ineligible receiver?

starting drag with actions: Copy Move Link
MT formats
MT retrieveData
MT retrieveData
MT retrieveData
MT retrieveData
MT retrieveData
exec returns 0 default 2 target <class 'NoneType'> source <class '__main__.SorcWidj'>

Depressing: the MIME data methods are just never called after the start of the drag, except when the drag enters a target in the same app.

It may be this behavior is peculiar to Mac OS and the code would behave differently on Windows or Linux. Doesn't matter; Mac OS is one of two main targets for my app, and anyway I want to keep it platform independent.

For the moment my desire to detect the drag of a tab off the edge of its parent window—and for that matter, the complementary desire to detect the drag of a QDialog onto a QTabBar—appears to be out of reach. But stay tuned, something may turn up.

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

Qt's Drag-and-Drop Architecture for Python and PyQt5
Pt. 8, Running Tests

To repeat, the entire code of the example program is in this PasteBin. Copy it; save it as draggin.py; and execute it from a command line:

$ python draggin.py

Try dragging and dropping:

  • From the source onto upper part of the target
  • Onto the forbidden (lower right) quadrant of the target
  • Onto another app that accepts text drops, like a text editor
  • Onto the desktop (in Mac OS, makes a "clipping" file)
  • Onto something that doesn't accept the drop

You can also start more than one copy:

$ python draggin.py &
$ python draggin.py &

Now you have two copies. Drag and drop from the source of one onto the target of the other and note, although the drop is accepted by a Qt widget, the source doesn't get any indication of success.

Next post: hacking the QDrag class for fun (but no profit)!

Monday, January 27, 2014

Qt's Drag-and-Drop Architecture for Python and PyQt5
Pt. 7, Drop the load

All right, let's drop that load!

    def dropEvent(self, event):
        msg = 'dropping at {0} {1}'.format(event.pos().x(), event.pos().y())
        actions = event.dropAction()
        print(msg, xlate_actions(actions))
        if actions & Qt.CopyAction :
            event.acceptProposedAction()
        else :
            print(' -- setting copy action!')
            event.setDropAction(Qt.CopyAction)
        self.setText( event.mimeData().text() )
        event.accept()

If dragMoveEvent() is not implemented, or it executes event.accept() just before the mouse button is released, the drop proceeds by calling your dropEvent() method.

The first three lines above just print debugging info; they would not be in production code.

The next lines make sure that we will do a copy and that the drag source widget will know this was the case. The event.dropaction() value is the value that will be returned by the drag object's exec_() method, so we force it to Copy if it isn't already Copy.

Of course, that is only useful if indeed the drag was started by a Qt drag source like the one we coded at a few posts back; and if that drag source is in this same Qt app. If the drag was begun in another Qt app, nothing is reported back; and if it began in a non-Qt app, who knows what it expects?

Finally we take the data from the MIME object and use it. In this pathetically simple example that means setting it as this QLabel's text. Then we accept the event, and this ends the drag successfully.

It is possible to reject the drop even at this late point. Your code could examine the MIME data and decide it is not acceptable, or perhaps some resource it needs is not available just now. If the drop can't be accepted for any reason, just call event.ignore() and exit. The drop fails.

Qt's Drag-and-Drop Architecture for Python and PyQt5
Pt. 6, Drag Moves

Once dragEnterEvent() has accepted the drag, the widget begins to receive a stream of calls to its dragMoveEvent() method. You don't need to implement this if you don't care where the drop happens upon your widget's rectangle. But here is an example of one.

    def dragMoveEvent(self, event):
        pos = event.pos()
        if pos != self.move_point:
            print('drag moving at {0} {1}'.format(pos.x(), pos.y()))
            self.move_point = pos
        # To illustrate forbidden areas, we mark the lower right quadrant as
        # invalid. The lower right quadrant is the rect with top-left at w/2,
        # h/2 and with size w/2, h/2. It doesn't make sense to specify this
        # over and over, but there's no other way.
        half_width = self.width()/2
        half_height = self.height()/2
        forbidden_rect = QRect(half_width,half_height,half_width,half_height)
        #event.ignore(forbidden_rect)
        if forbidden_rect.contains(pos):
            event.ignore()
        else:
            event.accept()

The first four lines implement a debugging display. These events are continuous and rapid, even if the mouse is not moving. (That's right: dragMoveEvent is called even when the mouse does not move!) For this reason we save the last-displayed point and only display again if the mouse has actually changed position.

You could track drag move events in order to change the appearance of the widget depending on the position of the cursor, for example changing its border, or somehow highlighting a child widget when the cursor was over it.

Another use is to put restrictions on the particular part of a widget that will receive the drop. The QDragMoveEvent reference claims that if you call event.ignore(rectangle), "Moves within the rectangle are not acceptable, and will be ignored." This does not seem to be true. If you enable the example line above, event.ignore(forbidden_rect), it has no effect on the behavior of the drag and drop operation. The drop will take place in the forbidden area if that's where the mouse is released.

What does make a difference is the explicit call to event.ignore() when the drag is moving within the forbidden rectangle. If the last call to dragMoveEvent before the mouse is released ends in event.ignore(), the drop doesn't happen. The cursor wanders away and the drag ends without a drop.

If you have changed the look of the widget at the start of the drag, or during the drag moves, you would like to change it back to normal if the drag doesn't happen. That's the purpose of this code:

    def dragLeaveEvent(self, event):
        print('drag leaving')
        event.accept()

This event is delivered in two cases: one, if the user drags the cursor out of the widget's boundary; and two, if the user releases the mouse button and your dragMoveEvent ends in event.ignore(). Either counts as the drag "leaving".

Next post: dropping a load.