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.
The original solution was posted below by Blackwood, the author of this thread, all credit is theirs alone
Thanks for this. I was looking exactly for this and happened upon this thread.
The application I was looking for uses PyQt5, which, although similar, has enough differences to break the code posted above.
I edited the code to make it work for PyQt5. It works, but is not properly bug tested
from PyQt5 import QtGui, QtCore,QtWidgets
from PyQt5.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
#
# Original by Stack Overflow user: Blackwood, 13/11/2017
#
# Adapted for PyQt5
#
class DetachableTabWidget(QtWidgets.QTabWidget):
def __init__(self, parent=None):
super().__init__()
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
QtWidgets.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(str, 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(QtWidgets.QMainWindow):
onCloseSignal = pyqtSignal(QtWidgets.QWidget, str, QtGui.QIcon)
onDropSignal = pyqtSignal(str, QtCore.QPoint)
def __init__(self, name, contentWidget):
QtWidgets.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(QtWidgets.QTabBar):
onDetachTabSignal = pyqtSignal(int, QtCore.QPoint)
onMoveTabSignal = pyqtSignal(int, int)
detachedTabDropSignal = pyqtSignal(str, int, QtCore.QPoint)
def __init__(self, parent=None):
QtWidgets.QTabBar.__init__(self, parent)
self.setAcceptDrops(True)
self.setElideMode(QtCore.Qt.ElideRight)
self.setSelectionBehaviorOnRemove(QtWidgets.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
QtWidgets.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() < QtWidgets.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)
QtWidgets.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)
# screen = QScreen(self.parentWidget().currentWidget().winId())
# Create the appearance of dragging the tab content
pixmap = self.parent().widget(self.tabAt(self.dragStartPos)).grab()
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:
QtWidgets.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()
QtWidgets.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()
QtWidgets.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 = QtWidgets.QApplication(sys.argv)
mainWindow = QtWidgets.QMainWindow()
tabWidget = DetachableTabWidget()
tab1 = QtWidgets.QLabel('Test Widget 1')
tabWidget.addTab(tab1, 'Tab1')
tab2 = QtWidgets.QLabel('Test Widget 2')
tabWidget.addTab(tab2, 'Tab2')
tab3 = QtWidgets.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
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.
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.I had some problems with the PyQt5 answer posted so I did my own refactor of Blackwood's code and got it working without removing any parts:
# https://stackoverflow.com/a/50693795/3620725
from PyQt5 import QtGui, QtCore, QtWidgets
from PyQt5.QtCore import pyqtSignal, pyqtSlot
class DetachableTabWidget(QtWidgets.QTabWidget):
def __init__(self, parent=None):
QtWidgets.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
QtWidgets.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 range(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(str, 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 tabDropPos in self.rect():
# 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(QtWidgets.QMainWindow):
onCloseSignal = pyqtSignal(QtWidgets.QWidget, str, QtGui.QIcon)
onDropSignal = pyqtSignal(str, QtCore.QPoint)
def __init__(self, name, contentWidget):
QtWidgets.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(QtWidgets.QTabBar):
onDetachTabSignal = pyqtSignal(int, QtCore.QPoint)
onMoveTabSignal = pyqtSignal(int, int)
detachedTabDropSignal = pyqtSignal(str, int, QtCore.QPoint)
def __init__(self, parent=None):
QtWidgets.QTabBar.__init__(self, parent)
self.setAcceptDrops(True)
self.setElideMode(QtCore.Qt.ElideRight)
self.setSelectionBehaviorOnRemove(QtWidgets.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
QtWidgets.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() < QtWidgets.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)
QtWidgets.QTabBar.mouseMoveEvent(self, finishMoveEvent)
# Convert the move event into a drag
drag = QtGui.QDrag(self)
mimeData = QtCore.QMimeData()
mimeData.setData('action', b'application/tab-detach')
drag.setMimeData(mimeData)
# Create the appearance of dragging the tab content
pixmap = self.parentWidget().currentWidget().grab()
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:
QtWidgets.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 'action' in formats and mimeData.data('action') == 'application/tab-detach':
event.acceptProposedAction()
QtWidgets.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()
QtWidgets.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 = QtWidgets.QApplication(sys.argv)
tabWidget = DetachableTabWidget()
tab1 = QtWidgets.QLabel('Test Widget 1')
tabWidget.addTab(tab1, 'Tab1')
tab2 = QtWidgets.QLabel('Test Widget 2')
tabWidget.addTab(tab2, 'Tab2')
tab3 = QtWidgets.QLabel('Test Widget 3')
tabWidget.addTab(tab3, 'Tab3')
tabWidget.show()
app.exec_()
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With