Search code examples
python-3.xpyside6

Drag and drop to move text


Copying text with drag and drop is clear to me, but I can't see how to move text, i.e. remove it from the source once the copy has been made.

I am attaching an example where it is enough to select text in the line edit and drag it to the button.

What I am looking for is how to delete the dragged text from the source. What I get is a copy-paste, not a cut-paste, which is what I would like.

from PySide6.QtWidgets import (
    QApplication,
    QMainWindow,
    QLineEdit,
    QPushButton,
    QVBoxLayout,
    QWidget,
    )

class Button(QPushButton):
    def __init__(self, title, parent):
        super().__init__(title, parent)

        self.setAcceptDrops(True)   

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

    def dropEvent(self, event):
        self.setText(event.mimeData().text())
        event.accept()

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        self.setWindowTitle('Drag and drop')
        self.resize(260, 130)

        # Definición de controles
        line_edit = QLineEdit()
        line_edit.setDragEnabled(True)
        line_edit.setText('Text to drag')
        line_edit.selectAll()
        
        button = Button("Button", self)

        layout = QVBoxLayout()
        layout.addWidget(line_edit)
        layout.addWidget(button)
        widget = QWidget()
        widget.setLayout(layout)
        self.setCentralWidget(widget)

if __name__ == "__main__":
    app = QApplication([])
    window = MainWindow()
    window.show()
    app.exec()

Solution

  • Being aware about Qt Drag and Drop

    An important thing to be aware of is that the Qt drag and drop API supports various types of actions.

    The most common one is CopyAction, but a QDrag object can be started with a combination of possible proposed actions, including MoveAction, which could theoretically mean that the contents specified in the drag object are being removed from the source when put on the target. That is what we're looking for.

    The "proposed drop actions" tell the target widget what it can eventually do with the contents of the drag object, and possibly tell which one of the proposed action it prefers, using setDropAction(); once the object is finally dropped, the source may eventually decide what to do based on the proposed action possibly selected by the target: if the proposed drag contains both CopyAction and MoveAction and the accepted drop action is the latter, then it should theoretically remove the related contents from itself.

    What QLineEdit does when dragging

    By checking the QLineEdit source code, it seems that this should be possible:

    void QLineEditPrivate::drag()
    {
        Q_Q(QLineEdit);
        dndTimer.stop();
        QMimeData *data = new QMimeData;
        data->setText(control->selectedText());
        QDrag *drag = new QDrag(q);
        drag->setMimeData(data);
        Qt::DropAction action = drag->exec(Qt::CopyAction);
        if (action == Qt::MoveAction && !control->isReadOnly() && drag->target() != q)
            control->removeSelection();
    }
    

    Interestingly enough, though, the exec() is called only with CopyAction, which prevents the setDropAction() to change anything different from the only proposed action; this should probably be reported in the Qt bug system.

    Unfortunately, the drag() function above is part of a private implementation of QLineEdit, meaning that it cannot be overridden.
    Therefore, implementing a more appropriate drag function in a QLineEdit subclass becomes quite complex, since it requires overriding many event handlers in complex ways (possibly copying/porting the current code and therefore breaking any future changes or ignoring behavior of previous versions). Furthermore, QLineEdit also uses internal timers that are not exposed to the Python bindings: most importantly the dndTimer that is automatically started on mouse press (effectively starting a drag after pressing the left button on a selection for some time) and the tripleClickTimer that is used to extend the selection to a full word after clicking three times.
    Since those timers are internal QBasicTimers, there is no reliable way to recognize them when they time out, and trying to override either event() or timerEvent() would require an arbitrary and unreliable guess.

    The only way to fully work around the above would require to override the event handlers and implement further timers that imitate the default behavior. It's not impossible, but it also is complex, other than difficult to achieve and maintain.

    A possibly simple, yet not perfect solution

    An alternative solution does exist. Even if probably not completely elegant, it is effective, and should be consistent no matter any previous or future implementation of the QLineEdit widget, unless its selection, mouse and drag/drop handling doesn't radically change.

    We still need to subclass QLineEdit, because the default behavior allows dropping text in itself, and also clears the selection as soon as something is dragged in it, including its own QDrag created above:

    • create an internal attribute containing the current selection, and set it as soon as a dragEnterEvent() is called (which may happen when dragging internally) but only if the attribute hasn't been set yet;
    • restore the selection whenever a dragLeaveEvent() is called; this is not very elegant from the UX perspective, but allows transparent interaction from external widgets;

    Then, in the dropEvent() of the target, we need to check whether the event source() is a QLineEdit. If it is, then we can clear its selection (since we restored it in the dragLeaveEvent()).

    class CustomLineEdit(QLineEdit):
        oldSelection = None
    
        def dragEnterEvent(self, event):
            if event.source() == self and self.oldSelection is None:
                self.oldSelection = self.selectionStart(), self.selectionLength()
            super().dragEnterEvent(event)
    
        def dragLeaveEvent(self, event):
            super().dragLeaveEvent(event)
            if self.oldSelection is not None:
                self.setSelection(*self.oldSelection)
                self.oldSelection = None
    
    
    class Button(QPushButton):
        ...
        def dropEvent(self, event):
            self.setText(event.mimeData().text())
            event.accept()
            if isinstance(event.source(), QLineEdit):
                le = event.source()
                text = le.text()
                le.setText(
                    text[:le.selectionStart()]
                    + text[le.selectionEnd():]
                )
    

    Issues about undo and complex input

    The most important issue about the above is that it completely clears the undo stack of the line edit.

    A possible work around to that would be to send an "synthetized" key press to the line edit (the Backspace or Delete keys), that will then keep the current undo stack.

    class Button(QPushButton):
        ...
        def dropEvent(self, event):
            self.setText(event.mimeData().text())
            event.accept()
            if (
                isinstance(event.source(), QLineEdit)
                and event.source().hasSelectedText()
            ):
                QApplication.postEvent(
                    event.source(), 
                    QKeyEvent(
                        QEvent.Type.KeyPress, Qt.Key.Key_Delete, 
                        Qt.KeyboardModifiers(0), 
                    )
                )
    

    Also consider that the above (the setText() call, but also the synthetic event approach) may have issues with complex input: languages and inputs that use preedit areas, or mixed RTL/LTR strings.

    In those cases, it may be better to interface with the internal (and undocumented) QWidgetLineControl, which is a child of QLineEdit and also provides a convenience _q_deleteSelected() function that clears the selection while being aware of both the undo stack and the input method.

    We could then find that object and call that function, but it's important to be aware that, being it a private object with an internal API, it may be subject to changes in future Qt versions or be inconsistent with previous ones: not only its behavior may change, but even its names (the object class and the function) may change as well, possibly causing the function to crash. And that is also valid while providing support for a previous version that used a different implementation as well.

    At the very least, a fall back system (using the postEvent() above) should be added.

    class Button(QPushButton):
        ...
        def dropEvent(self, event):
            self.setText(event.mimeData().text())
            event.accept()
            if (
                isinstance(event.source(), QLineEdit)
                and event.source().hasSelectedText()
            ):
                children = event.source().findChildren(
                    QObject, 
                    options=Qt.FindChildOption.FindDirectChildrenOnly
                )
                wlc = False
                for obj in children:
                    if obj.metaObject().className() == 'QWidgetLineControl':
                        wlc = True
                        try:
                            obj._q_deleteSelected()
                            return
                        except Exception as e:
                            print(
                                '"QWidgetLineControl" found, but the '
                                'call to "_q_deleteSelected()" failed.'
                            )
                            print(type(e), e)
                if not wlc:
                    print('No QWidgetLineControl found')
                print('Falling back to synthetized "Delete" key press.')
                QApplication.postEvent(
                    event.source(), 
                    QKeyEvent(
                        QEvent.Type.KeyPress, Qt.Key.Key_Delete, 
                        Qt.KeyboardModifiers(0), 
                    )
                )