PyQt5 move QDockWidget by dragging tab

Question:

The clip below shows dragging QDockWidgets between docking areas by dragging the tabs (not the title bar) – but when I try this with PyQt 5.15.0 it doesn’t work, the tabs won’t detach. How can I enable this behavior?

What I want:
https://www.screencast.com/t/lv83SoyVUhbd (from https://woboq.com/blog/qdockwidget-changes-in-56.html)

What I get:
https://www.screencast.com/t/bIUj4vLNTF

My code:

from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import Qt

if __name__ == "__main__":
    app = QtWidgets.QApplication([])

    main = QtWidgets.QMainWindow()

    dock1 = QtWidgets.QDockWidget("Blue")
    dock2 = QtWidgets.QDockWidget("Green")
    dock3 = QtWidgets.QDockWidget("Red")

    content1 = QtWidgets.QWidget()
    content1.setStyleSheet("background-color:blue;")

    content2 = QtWidgets.QWidget()
    content2.setStyleSheet("background-color:green;")

    content3 = QtWidgets.QWidget()
    content3.setStyleSheet("background-color:red;")

    dock1.setWidget(content1)
    dock2.setWidget(content2)
    dock3.setWidget(content3)

    dock1.setAllowedAreas(Qt.AllDockWidgetAreas)
    dock2.setAllowedAreas(Qt.AllDockWidgetAreas)
    dock3.setAllowedAreas(Qt.AllDockWidgetAreas)

    main.addDockWidget(Qt.LeftDockWidgetArea, dock1)
    main.tabifyDockWidget(dock1, dock2)
    main.addDockWidget(Qt.RightDockWidgetArea, dock3)

    main.resize(400, 200)
    main.show()

    app.exec_()
Asked By: pyjamas

||

Answers:

The solution to my question was enabling the GroupedDragging with setDockOptions on the QMainWindow. I managed to get a really nice appearance & behavior exactly like I wanted with the code below.

Demo: https://www.screencast.com/t/GU5Z2ysT

from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import Qt


class DockWidget(QtWidgets.QDockWidget):
    def __init__(self, title: str):
        super().__init__(title)
        self.setTitleBarWidget(QtWidgets.QWidget())
        self.dockLocationChanged.connect(self.on_dockLocationChanged)

    def on_dockLocationChanged(self):
        main: QtWidgets.QMainWindow = self.parent()
        all_dock_widgets = main.findChildren(QtWidgets.QDockWidget)

        for dock_widget in all_dock_widgets:
            sibling_tabs = main.tabifiedDockWidgets(dock_widget)
            # If you pull a tab out of a group the other tabs still see it as a sibling while dragging...
            sibling_tabs = [s for s in sibling_tabs if not s.isFloating()]

            if len(sibling_tabs) != 0:
                # Hide title bar
                dock_widget.setTitleBarWidget(QtWidgets.QWidget())
            else:
                # Re-enable title bar
                dock_widget.setTitleBarWidget(None)

    def minimumSizeHint(self) -> QtCore.QSize:
        return QtCore.QSize(100, 100)


if __name__ == "__main__":
    app = QtWidgets.QApplication([])
    main = QtWidgets.QMainWindow()

    dock1 = DockWidget("Blue")
    dock2 = DockWidget("Green")
    dock3 = DockWidget("Red")

    content1 = QtWidgets.QWidget()
    content1.setStyleSheet("background-color:blue;")
    content1.setMinimumSize(QtCore.QSize(50, 50))

    content2 = QtWidgets.QWidget()
    content2.setStyleSheet("background-color:green;")
    content2.setMinimumSize(QtCore.QSize(50, 50))

    content3 = QtWidgets.QWidget()
    content3.setStyleSheet("background-color:red;")
    content3.setMinimumSize(QtCore.QSize(50, 50))

    dock1.setWidget(content1)
    dock2.setWidget(content2)
    dock3.setWidget(content3)

    dock1.setAllowedAreas(Qt.AllDockWidgetAreas)
    dock2.setAllowedAreas(Qt.AllDockWidgetAreas)
    dock3.setAllowedAreas(Qt.AllDockWidgetAreas)

    main.addDockWidget(Qt.LeftDockWidgetArea, dock1)
    main.tabifyDockWidget(dock1, dock2)
    main.addDockWidget(Qt.RightDockWidgetArea, dock3)

    main.setDockOptions(main.GroupedDragging | main.AllowTabbedDocks | main.AllowNestedDocks)

    main.setTabPosition(Qt.AllDockWidgetAreas, QtWidgets.QTabWidget.North)
    main.resize(400, 200)
    main.show()

    app.exec_()

Answered By: pyjamas

Posting this as a little extra to the accepted answer.
This version works with Qt5 and Qt6 and is able to detect corner cases like pulling out a tab and dropping it in the same group or merging a floating tab group with a docked window:

from PySide6 import QtCore, QtWidgets
from PySide6.QtCore import Qt

from typing import TypeVar, List, Optional

TDockWidget = TypeVar('TDockWidget', bound='DockWidget')


class DockWidget(QtWidgets.QDockWidget):
    def __init__(self: TDockWidget, title: str, parent: Optional[QtWidgets.QWidget] = None) -> None:
        super(_DockWidget, self).__init__(title, parent)
        self.setTitleBarWidget(QtWidgets.QWidget())
        self.visibilityChanged.connect(self.on_visibility_changed)
        self.dockLocationChanged.connect(self.on_dock_location_changed)

    @QtCore.Slot(bool)
    def on_visibility_changed(self: TDockWidget, is_visible: bool) -> None:
        # this visibility monitor is really only needed to detect merges of
        # tabbed, floating windows with existing docked windows
        if not is_visible and isinstance(self.parent(), QtWidgets.QMainWindow):
            main_window: QtWidgets.QMainWindow = self.parent()
            all_dockwidgets: List[QtWidgets.QDockWidget] = main_window.findChildren(QtWidgets.QDockWidget)
            for dockwidget in all_dockwidgets:
                if hasattr(dockwidget, 'on_dock_location_changed'):
                    dockwidget.on_dock_location_changed(main_window.dockWidgetArea(dockwidget), False)

    @QtCore.Slot(Qt.DockWidgetArea)
    def on_dock_location_changed(self: TDockWidget, area: Qt.DockWidgetArea, update_others: bool = True) -> None:
        if not isinstance(self.parent(), QtWidgets.QMainWindow):
            # mysterious parents call for a title
            self.setTitleBarWidget(None)
            return

        main_window: QtWidgets.QMainWindow = self.parent()
        if not main_window.tabifiedDockWidgets(self):
            # if there's no siblings we ain't a tab!
            self.setTitleBarWidget(None)

            if not update_others:
                # prevent infinite recursion
                return

            # force an update to all other docks that may now no longer be tabs
            all_dockwidgets: List[QtWidgets.QDockWidget] = main_window.findChildren(QtWidgets.QDockWidget)
            for dockwidget in all_dockwidgets:
                if dockwidget != self and hasattr(dockwidget, 'on_dock_location_changed'):
                    dockwidget.on_dock_location_changed(main_window.dockWidgetArea(dockwidget), False)
            return

        # at this point the dockwidget is either a resting tab or a tab
        # that is being dragged and hasn't been dropped yet (siblings are updated post-drop)
        # collect all siblings of this dockwidget...
        tab_siblings: List[QtWidgets.QDockWidget] = main_window.tabifiedDockWidgets(self)
        # and filter for non-floating siblings in the same area
        tab_siblings = [x for x in tab_siblings if main_window.dockWidgetArea(x) == area and not x.isFloating()]

        if tab_siblings:
            if self.titleBarWidget() is not None:
                # no changes needed, prevent infinite recursion
                return

            # show a title if we're not floating (this tab is settled),
            # hide it otherwise (this tab just became floating but wasn't dropped)
            self.setTitleBarWidget(QtWidgets.QWidget() if not self.isFloating() else None)

            # in this case it's also a good idea to tell to reconsider their situation
            # since Qt won't notify them separately
            for sibling in tab_siblings:
                if hasattr(sibling, 'on_dock_location_changed'):
                    sibling.on_dock_location_changed(main_window.dockWidgetArea(sibling), True)
        else:
            self.setTitleBarWidget(None)


if __name__ == "__main__":
    app = QtWidgets.QApplication([])
    main = QtWidgets.QMainWindow()

    dock1 = DockWidget("Blue")
    dock2 = DockWidget("Green")
    dock3 = DockWidget("Red")

    content1 = QtWidgets.QWidget()
    content1.setStyleSheet("background-color:blue;")
    content1.setMinimumSize(QtCore.QSize(50, 50))

    content2 = QtWidgets.QWidget()
    content2.setStyleSheet("background-color:green;")
    content2.setMinimumSize(QtCore.QSize(50, 50))

    content3 = QtWidgets.QWidget()
    content3.setStyleSheet("background-color:red;")
    content3.setMinimumSize(QtCore.QSize(50, 50))

    dock1.setWidget(content1)
    dock2.setWidget(content2)
    dock3.setWidget(content3)

    dock1.setAllowedAreas(Qt.AllDockWidgetAreas)
    dock2.setAllowedAreas(Qt.AllDockWidgetAreas)
    dock3.setAllowedAreas(Qt.AllDockWidgetAreas)

    main.addDockWidget(Qt.LeftDockWidgetArea, dock1)
    main.tabifyDockWidget(dock1, dock2)
    main.addDockWidget(Qt.RightDockWidgetArea, dock3)

    main.setDockOptions(main.GroupedDragging | main.AllowTabbedDocks | main.AllowNestedDocks)

    main.setTabPosition(Qt.AllDockWidgetAreas, QtWidgets.QTabWidget.North)
    main.resize(400, 200)
    main.show()

    app.exec_()

Answered By: Shirkrin
Categories: questions Tags: , , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.