Search code examples
pythonqtpyqtpyqt4qtabwidget

In PyQt, is it possible to detach tabs from a QTabWidget?


Many professional applications (such as web browsers) give the user the ability to detach tabs from a tab bar. Surprisingly, Qt4 does not provide this functionality. Some might say that this functionality is provided by using tabified QDockWidgets. However, it could also be argued that QDockWidgets implementation makes it look unprofessional and un-intuitive to users.


Solution

  • I found a partially working C++ example in this post on the Qt Centre forum. It was incomplete and buggy. However, I was able to use it as reference and a starting point to create my own DetachableTabWidget using PyQt. Since I haven't been able to find any other fully functional examples of this in PyQt, I wanted to post this here. Maybe it will be useful to someone.

    I wouldn't call it perfect, so I am definitely open to any suggestions for improvement.

    EDIT1

    The previous iteration had some serious flaws that I did not discover until I tried to use it in a real-world application. I used QDialog for the detached tabs which meant that they could not be minimized or maximized like a typical window. I also had them parented by the tab host which meant that the detached tabs were always on top of the tab host. The following is my new version that uses QMainWindow for the detached tabs.

    EDIT2

    As stated in Qt's documentation, QDrag.exec_() behaves differently between Windows and Linux. This caused the iteration I posted for EDIT1 to loose the ability to move (reorder) tabs in Linux. I made a small fix to this iteration so that it now works on both Windows and Linux. I also updated the comments to reflect the swap from QDialog to QMainWindow.

    EDIT3

    I added a removeTabByName(name) function that will remove a tab by name, even if it is detached.

    I added the ability to reattach a tab by dragging it back into the tab bar area. If it is dropped on another tab, it will be inserted into that position. If it is dropped beside the tab bar, it is appended as the last tab. If all tabs are detached, then dropping the detached tab anywhere in the DetachableTabWidget will re-attach the tab.

    from PyQt4 import QtGui, QtCore
    from PyQt4.QtCore import pyqtSignal, pyqtSlot
    
    ##
    # The DetachableTabWidget adds additional functionality to Qt's QTabWidget that allows it
    # to detach and re-attach tabs.
    #
    # Additional Features:
    #   Detach tabs by
    #     dragging the tabs away from the tab bar
    #     double clicking the tab
    #   Re-attach tabs by
    #     dragging the detached tab's window into the tab bar
    #     closing the detached tab's window
    #   Remove tab (attached or detached) by name
    #
    # Modified Features:
    #   Re-ordering (moving) tabs by dragging was re-implemented  
    #   
    class DetachableTabWidget(QtGui.QTabWidget):
        def __init__(self, parent=None):
            QtGui.QTabWidget.__init__(self, parent)
    
            self.tabBar = self.TabBar(self)
            self.tabBar.onDetachTabSignal.connect(self.detachTab)
            self.tabBar.onMoveTabSignal.connect(self.moveTab)
            self.tabBar.detachedTabDropSignal.connect(self.detachedTabDrop)
    
            self.setTabBar(self.tabBar)
    
            # Used to keep a reference to detached tabs since their QMainWindow
            # does not have a parent
            self.detachedTabs = {}
    
            # Close all detached tabs if the application is closed explicitly
            QtGui.qApp.aboutToQuit.connect(self.closeDetachedTabs) # @UndefinedVariable
    
    
        ##
        #  The default movable functionality of QTabWidget must remain disabled
        #  so as not to conflict with the added features
        def setMovable(self, movable):
            pass
    
        ##
        #  Move a tab from one position (index) to another
        #
        #  @param    fromIndex    the original index location of the tab
        #  @param    toIndex      the new index location of the tab
        @pyqtSlot(int, int)
        def moveTab(self, fromIndex, toIndex):
            widget = self.widget(fromIndex)
            icon = self.tabIcon(fromIndex)
            text = self.tabText(fromIndex)
    
            self.removeTab(fromIndex)
            self.insertTab(toIndex, widget, icon, text)
            self.setCurrentIndex(toIndex)
    
    
        ##
        #  Detach the tab by removing it's contents and placing them in
        #  a DetachedTab window
        #
        #  @param    index    the index location of the tab to be detached
        #  @param    point    the screen position for creating the new DetachedTab window
        @pyqtSlot(int, QtCore.QPoint)
        def detachTab(self, index, point):
    
            # Get the tab content
            name = self.tabText(index)
            icon = self.tabIcon(index)        
            if icon.isNull():
                icon = self.window().windowIcon()              
            contentWidget = self.widget(index)
    
            try:
                contentWidgetRect = contentWidget.frameGeometry()
            except AttributeError:
                return
    
            # Create a new detached tab window
            detachedTab = self.DetachedTab(name, contentWidget)
            detachedTab.setWindowModality(QtCore.Qt.NonModal)
            detachedTab.setWindowIcon(icon)
            detachedTab.setGeometry(contentWidgetRect)
            detachedTab.onCloseSignal.connect(self.attachTab)
            detachedTab.onDropSignal.connect(self.tabBar.detachedTabDrop)
            detachedTab.move(point)
            detachedTab.show()
    
    
            # Create a reference to maintain access to the detached tab
            self.detachedTabs[name] = detachedTab
    
    
        ##
        #  Re-attach the tab by removing the content from the DetachedTab window,
        #  closing it, and placing the content back into the DetachableTabWidget
        #
        #  @param    contentWidget    the content widget from the DetachedTab window
        #  @param    name             the name of the detached tab
        #  @param    icon             the window icon for the detached tab
        #  @param    insertAt         insert the re-attached tab at the given index
        def attachTab(self, contentWidget, name, icon, insertAt=None):
    
            # Make the content widget a child of this widget
            contentWidget.setParent(self)
    
    
            # Remove the reference
            del self.detachedTabs[name]
    
    
            # Create an image from the given icon (for comparison)
            if not icon.isNull():
                try:
                    tabIconPixmap = icon.pixmap(icon.availableSizes()[0])
                    tabIconImage = tabIconPixmap.toImage()
                except IndexError:
                    tabIconImage = None
            else:
                tabIconImage = None
    
    
            # Create an image of the main window icon (for comparison)
            if not icon.isNull():
                try:
                    windowIconPixmap = self.window().windowIcon().pixmap(icon.availableSizes()[0])
                    windowIconImage = windowIconPixmap.toImage()
                except IndexError:
                    windowIconImage = None
            else:
                windowIconImage = None
    
    
            # Determine if the given image and the main window icon are the same.
            # If they are, then do not add the icon to the tab
            if tabIconImage == windowIconImage:
                if insertAt == None:
                    index = self.addTab(contentWidget, name)
                else:
                    index = self.insertTab(insertAt, contentWidget, name)
            else:
                if insertAt == None:
                    index = self.addTab(contentWidget, icon, name)
                else:
                    index = self.insertTab(insertAt, contentWidget, icon, name)
    
    
            # Make this tab the current tab
            if index > -1:
                self.setCurrentIndex(index)
    
    
        ##
        #  Remove the tab with the given name, even if it is detached
        #
        #  @param    name    the name of the tab to be removed
        def removeTabByName(self, name):
    
            # Remove the tab if it is attached
            attached = False
            for index in xrange(self.count()):
                if str(name) == str(self.tabText(index)):
                    self.removeTab(index)
                    attached = True
                    break
    
    
            # If the tab is not attached, close it's window and
            # remove the reference to it
            if not attached:
                for key in self.detachedTabs:
                    if str(name) == str(key):
                        self.detachedTabs[key].onCloseSignal.disconnect()
                        self.detachedTabs[key].close()
                        del self.detachedTabs[key]
                        break
    
    
        ##
        #  Handle dropping of a detached tab inside the DetachableTabWidget
        #
        #  @param    name     the name of the detached tab
        #  @param    index    the index of an existing tab (if the tab bar
        #                     determined that the drop occurred on an
        #                     existing tab)
        #  @param    dropPos  the mouse cursor position when the drop occurred
        @QtCore.pyqtSlot(QtCore.QString, int, QtCore.QPoint)
        def detachedTabDrop(self, name, index, dropPos):
    
            # If the drop occurred on an existing tab, insert the detached
            # tab at the existing tab's location
            if index > -1:
    
                # Create references to the detached tab's content and icon
                contentWidget = self.detachedTabs[name].contentWidget
                icon = self.detachedTabs[name].windowIcon()
    
                # Disconnect the detached tab's onCloseSignal so that it
                # does not try to re-attach automatically
                self.detachedTabs[name].onCloseSignal.disconnect()
    
                # Close the detached
                self.detachedTabs[name].close()
    
                # Re-attach the tab at the given index
                self.attachTab(contentWidget, name, icon, index)
    
    
            # If the drop did not occur on an existing tab, determine if the drop
            # occurred in the tab bar area (the area to the side of the QTabBar)
            else:
    
                # Find the drop position relative to the DetachableTabWidget
                tabDropPos = self.mapFromGlobal(dropPos)
    
                # If the drop position is inside the DetachableTabWidget...
                if self.rect().contains(tabDropPos):                
    
                    # If the drop position is inside the tab bar area (the
                    # area to the side of the QTabBar) or there are not tabs
                    # currently attached...
                    if tabDropPos.y() < self.tabBar.height() or self.count() == 0:
    
                        # Close the detached tab and allow it to re-attach
                        # automatically
                        self.detachedTabs[name].close()
    
    
        ##
        #  Close all tabs that are currently detached.
        def closeDetachedTabs(self):
            listOfDetachedTabs = []
    
            for key in self.detachedTabs:
                listOfDetachedTabs.append(self.detachedTabs[key])
    
            for detachedTab in listOfDetachedTabs:
                detachedTab.close()
    
    
        ##
        #  When a tab is detached, the contents are placed into this QMainWindow.  The tab
        #  can be re-attached by closing the dialog or by dragging the window into the tab bar
        class DetachedTab(QtGui.QMainWindow):
            onCloseSignal = pyqtSignal(QtGui.QWidget, QtCore.QString, QtGui.QIcon)
            onDropSignal = pyqtSignal(QtCore.QString, QtCore.QPoint)
    
            def __init__(self, name, contentWidget):
                QtGui.QMainWindow.__init__(self, None)
    
                self.setObjectName(name)
                self.setWindowTitle(name)
    
                self.contentWidget = contentWidget
                self.setCentralWidget(self.contentWidget)
                self.contentWidget.show()
    
                self.windowDropFilter = self.WindowDropFilter()
                self.installEventFilter(self.windowDropFilter)
                self.windowDropFilter.onDropSignal.connect(self.windowDropSlot)
    
    
            ##
            #  Handle a window drop event
            #
            #  @param    dropPos    the mouse cursor position of the drop
            @QtCore.pyqtSlot(QtCore.QPoint)
            def windowDropSlot(self, dropPos):
                self.onDropSignal.emit(self.objectName(), dropPos)
    
    
            ##
            #  If the window is closed, emit the onCloseSignal and give the
            #  content widget back to the DetachableTabWidget
            #
            #  @param    event    a close event
            def closeEvent(self, event):
                self.onCloseSignal.emit(self.contentWidget, self.objectName(), self.windowIcon())
    
    
            ##
            #  An event filter class to detect a QMainWindow drop event
            class WindowDropFilter(QtCore.QObject):
                onDropSignal = pyqtSignal(QtCore.QPoint)
    
                def __init__(self):
                    QtCore.QObject.__init__(self)
                    self.lastEvent = None
    
    
                ##
                #  Detect a QMainWindow drop event by looking for a NonClientAreaMouseMove (173)
                #  event that immediately follows a Move event
                #
                #  @param    obj    the object that generated the event
                #  @param    event  the current event
                def eventFilter(self, obj, event):
    
                    # If a NonClientAreaMouseMove (173) event immediately follows a Move event...
                    if self.lastEvent == QtCore.QEvent.Move and event.type() == 173:
    
                        # Determine the position of the mouse cursor and emit it with the
                        # onDropSignal
                        mouseCursor = QtGui.QCursor()
                        dropPos = mouseCursor.pos()                    
                        self.onDropSignal.emit(dropPos)                    
                        self.lastEvent = event.type()                    
                        return True
    
                    else:
                        self.lastEvent = event.type()
                        return False
    
    
        ##
        #  The TabBar class re-implements some of the functionality of the QTabBar widget
        class TabBar(QtGui.QTabBar):
            onDetachTabSignal = pyqtSignal(int, QtCore.QPoint)
            onMoveTabSignal = pyqtSignal(int, int)
            detachedTabDropSignal = pyqtSignal(QtCore.QString, int, QtCore.QPoint)
    
            def __init__(self, parent=None):
                QtGui.QTabBar.__init__(self, parent)
    
                self.setAcceptDrops(True)
                self.setElideMode(QtCore.Qt.ElideRight)
                self.setSelectionBehaviorOnRemove(QtGui.QTabBar.SelectLeftTab)
    
                self.dragStartPos = QtCore.QPoint()
                self.dragDropedPos = QtCore.QPoint()
                self.mouseCursor = QtGui.QCursor()
                self.dragInitiated = False
    
    
            ##
            #  Send the onDetachTabSignal when a tab is double clicked
            #
            #  @param    event    a mouse double click event
            def mouseDoubleClickEvent(self, event):
                event.accept()
                self.onDetachTabSignal.emit(self.tabAt(event.pos()), self.mouseCursor.pos())
    
    
            ##
            #  Set the starting position for a drag event when the mouse button is pressed
            #
            #  @param    event    a mouse press event
            def mousePressEvent(self, event):
                if event.button() == QtCore.Qt.LeftButton:
                    self.dragStartPos = event.pos()
    
                self.dragDropedPos.setX(0)
                self.dragDropedPos.setY(0)
    
                self.dragInitiated = False
    
                QtGui.QTabBar.mousePressEvent(self, event)
    
    
            ##
            #  Determine if the current movement is a drag.  If it is, convert it into a QDrag.  If the
            #  drag ends inside the tab bar, emit an onMoveTabSignal.  If the drag ends outside the tab
            #  bar, emit an onDetachTabSignal.
            #
            #  @param    event    a mouse move event
            def mouseMoveEvent(self, event):
    
                # Determine if the current movement is detected as a drag
                if not self.dragStartPos.isNull() and ((event.pos() - self.dragStartPos).manhattanLength() < QtGui.QApplication.startDragDistance()):
                    self.dragInitiated = True
    
                # If the current movement is a drag initiated by the left button
                if (((event.buttons() & QtCore.Qt.LeftButton)) and self.dragInitiated):
    
                    # Stop the move event
                    finishMoveEvent = QtGui.QMouseEvent(QtCore.QEvent.MouseMove, event.pos(), QtCore.Qt.NoButton, QtCore.Qt.NoButton, QtCore.Qt.NoModifier)
                    QtGui.QTabBar.mouseMoveEvent(self, finishMoveEvent)
    
                    # Convert the move event into a drag
                    drag = QtGui.QDrag(self)
                    mimeData = QtCore.QMimeData()
                    mimeData.setData('action', 'application/tab-detach')
                    drag.setMimeData(mimeData)
    
                    # Create the appearance of dragging the tab content
                    pixmap = QtGui.QPixmap.grabWindow(self.parentWidget().currentWidget().winId())
                    targetPixmap = QtGui.QPixmap(pixmap.size())
                    targetPixmap.fill(QtCore.Qt.transparent)
                    painter = QtGui.QPainter(targetPixmap)
                    painter.setOpacity(0.85)
                    painter.drawPixmap(0, 0, pixmap)
                    painter.end()
                    drag.setPixmap(targetPixmap)
    
                    # Initiate the drag
                    dropAction = drag.exec_(QtCore.Qt.MoveAction | QtCore.Qt.CopyAction)
    
    
                    # For Linux:  Here, drag.exec_() will not return MoveAction on Linux.  So it
                    #             must be set manually
                    if self.dragDropedPos.x() != 0 and self.dragDropedPos.y() != 0:
                        dropAction = QtCore.Qt.MoveAction
    
    
                    # If the drag completed outside of the tab bar, detach the tab and move
                    # the content to the current cursor position
                    if dropAction == QtCore.Qt.IgnoreAction:
                        event.accept()
                        self.onDetachTabSignal.emit(self.tabAt(self.dragStartPos), self.mouseCursor.pos())
    
                    # Else if the drag completed inside the tab bar, move the selected tab to the new position
                    elif dropAction == QtCore.Qt.MoveAction:
                        if not self.dragDropedPos.isNull():
                            event.accept()
                            self.onMoveTabSignal.emit(self.tabAt(self.dragStartPos), self.tabAt(self.dragDropedPos))
                else:
                    QtGui.QTabBar.mouseMoveEvent(self, event)
    
    
            ##
            #  Determine if the drag has entered a tab position from another tab position
            #
            #  @param    event    a drag enter event
            def dragEnterEvent(self, event):
                mimeData = event.mimeData()
                formats = mimeData.formats()
    
                if formats.contains('action') and mimeData.data('action') == 'application/tab-detach':
                    event.acceptProposedAction()
    
                QtGui.QTabBar.dragMoveEvent(self, event)
    
    
            ##
            #  Get the position of the end of the drag
            #
            #  @param    event    a drop event
            def dropEvent(self, event):
                self.dragDropedPos = event.pos()
                QtGui.QTabBar.dropEvent(self, event)
    
    
            ##
            #  Determine if the detached tab drop event occurred on an existing tab,
            #  then send the event to the DetachableTabWidget
            def detachedTabDrop(self, name, dropPos):
    
                tabDropPos = self.mapFromGlobal(dropPos)
    
                index = self.tabAt(tabDropPos)
    
                self.detachedTabDropSignal.emit(name, index, dropPos)
    
    
    
    if __name__ == '__main__':
        import sys
    
        app = QtGui.QApplication(sys.argv)
    
        mainWindow = QtGui.QMainWindow()
        tabWidget = DetachableTabWidget()
    
        tab1 = QtGui.QLabel('Test Widget 1')    
        tabWidget.addTab(tab1, 'Tab1')
    
        tab2 = QtGui.QLabel('Test Widget 2')
        tabWidget.addTab(tab2, 'Tab2')
    
        tab3 = QtGui.QLabel('Test Widget 3')
        tabWidget.addTab(tab3, 'Tab3')
    
        tabWidget.show()
        mainWindow.setCentralWidget(tabWidget)
        mainWindow.show()
    
        try:
            exitStatus = app.exec_()
            print 'Done...'
            sys.exit(exitStatus)
        except:
            pass
    

    Shortcomings

    Unlike a web browser, closing a window (detached tab) will always reattach it to the tab bar. In the future I would like to add an option to be able to close the window instead of reattaching.

    I still need to add a method for getting a reference to a detached tab from outside of the widget.

    Bugs

    Any help with these would be greatly appreciated.

    1. On very rare occasions, the drag event is not detected as a drag when removing a tab by dragging. This is rare enough that I haven't spent much time on it.
    2. Sometimes when a tab is detached, the QMainWindow that holds the detached tab will not generate the NonClientAreaMouseMove event. The only way to make it start generating this event again is for the QMainWindow to loose and regain focus. I'm not sure if this is a bug on my end or not.