在 PyQt 中,是否可以从 QTabWidget 中分离标签?
In PyQt, is it possible to detach tabs from a QTabWidget?
许多专业应用程序(例如 Web 浏览器)使用户能够从选项卡栏中分离选项卡。奇怪的是,Qt4 并没有提供这个功能。有人可能会说这个功能是通过使用表格化的 QDockWidgets 提供的。然而,也有人认为 QDockWidgets 的实现让用户看起来不专业且不直观。
我在 Qt Center 论坛的 this post 中找到了一个部分工作的 C++ 示例。它不完整且有问题。但是,我能够将其用作参考和起点,以使用 PyQt 创建我自己的 DetachableTabWidget。由于我无法在 PyQt 中找到任何其他功能齐全的示例,因此我想在此处 post。也许对某人有用。
我不认为它是完美的,所以我绝对乐于接受任何改进建议。
EDIT1
之前的迭代有一些严重的缺陷,直到我尝试在实际应用程序中使用它时我才发现这些缺陷。我将 QDialog 用于分离的选项卡,这意味着它们不能像典型的 window 那样被最小化或最大化。我还让它们由选项卡主机作为父级,这意味着分离的选项卡始终位于选项卡主机之上。以下是我的新版本,它使用 QMainWindow 作为分离的选项卡。
EDIT2
如 Qt 文档中所述,QDrag.exec_()
在 Windows 和 Linux 之间的行为不同。这导致我为 EDIT1 post 编辑的迭代失去了在 Linux 中移动(重新排序)选项卡的能力。我对这个迭代做了一个小的修复,所以它现在可以在 Windows 和 Linux 上工作。我还更新了评论以反映从 QDialog 到 QMainWindow 的交换。
EDIT3
我添加了一个 removeTabByName(name)
功能,可以按名称删除选项卡,即使它是分离的。
我添加了通过将选项卡拖回选项卡栏区域来重新附加选项卡的功能。如果它被放在另一个选项卡上,它将被插入到那个位置。如果将其放在选项卡栏旁边,则会将其附加为最后一个选项卡。如果所有选项卡都已分离,则将分离的选项卡放在 DetachableTabWidget 中的任意位置将重新附加该选项卡。
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
缺点
与网络浏览器不同,关闭 window(分离的选项卡)将始终将其重新附加到选项卡栏。将来我想添加一个选项来关闭 window 而不是重新附加。
我仍然需要添加一种方法来从小部件外部获取对分离选项卡的引用。
错误
如有任何帮助,我们将不胜感激。
- 在极少数情况下,通过拖动删除选项卡时,拖动事件不会被检测为拖动。这是非常罕见的,我没有花太多时间在这上面。
- 有时当标签被分离时,持有分离标签的 QMainWindow 不会生成
NonClientAreaMouseMove
事件。让它再次开始生成此事件的唯一方法是让 QMainWindow 松开并重新获得焦点。我不确定这是否是我的错误。
原始解决方案由本主题的作者 Blackwood 在下方发布,所有功劳归他们所有
谢谢。我正在寻找这个并且偶然发现了这个线程。
我正在寻找的应用程序使用 PyQt5,它虽然相似,但有足够的差异来破坏上面发布的代码。
我编辑了代码以使其适用于 PyQt5。它可以工作,但没有经过适当的错误测试
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
我在发布 PyQt5 答案时遇到了一些问题,所以我对 Blackwood 的代码进行了自己的重构,并且在不删除任何部分的情况下让它工作:
#
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_()
许多专业应用程序(例如 Web 浏览器)使用户能够从选项卡栏中分离选项卡。奇怪的是,Qt4 并没有提供这个功能。有人可能会说这个功能是通过使用表格化的 QDockWidgets 提供的。然而,也有人认为 QDockWidgets 的实现让用户看起来不专业且不直观。
我在 Qt Center 论坛的 this post 中找到了一个部分工作的 C++ 示例。它不完整且有问题。但是,我能够将其用作参考和起点,以使用 PyQt 创建我自己的 DetachableTabWidget。由于我无法在 PyQt 中找到任何其他功能齐全的示例,因此我想在此处 post。也许对某人有用。
我不认为它是完美的,所以我绝对乐于接受任何改进建议。
EDIT1
之前的迭代有一些严重的缺陷,直到我尝试在实际应用程序中使用它时我才发现这些缺陷。我将 QDialog 用于分离的选项卡,这意味着它们不能像典型的 window 那样被最小化或最大化。我还让它们由选项卡主机作为父级,这意味着分离的选项卡始终位于选项卡主机之上。以下是我的新版本,它使用 QMainWindow 作为分离的选项卡。
EDIT2
如 Qt 文档中所述,QDrag.exec_()
在 Windows 和 Linux 之间的行为不同。这导致我为 EDIT1 post 编辑的迭代失去了在 Linux 中移动(重新排序)选项卡的能力。我对这个迭代做了一个小的修复,所以它现在可以在 Windows 和 Linux 上工作。我还更新了评论以反映从 QDialog 到 QMainWindow 的交换。
EDIT3
我添加了一个 removeTabByName(name)
功能,可以按名称删除选项卡,即使它是分离的。
我添加了通过将选项卡拖回选项卡栏区域来重新附加选项卡的功能。如果它被放在另一个选项卡上,它将被插入到那个位置。如果将其放在选项卡栏旁边,则会将其附加为最后一个选项卡。如果所有选项卡都已分离,则将分离的选项卡放在 DetachableTabWidget 中的任意位置将重新附加该选项卡。
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
缺点
与网络浏览器不同,关闭 window(分离的选项卡)将始终将其重新附加到选项卡栏。将来我想添加一个选项来关闭 window 而不是重新附加。
我仍然需要添加一种方法来从小部件外部获取对分离选项卡的引用。
错误
如有任何帮助,我们将不胜感激。
- 在极少数情况下,通过拖动删除选项卡时,拖动事件不会被检测为拖动。这是非常罕见的,我没有花太多时间在这上面。
- 有时当标签被分离时,持有分离标签的 QMainWindow 不会生成
NonClientAreaMouseMove
事件。让它再次开始生成此事件的唯一方法是让 QMainWindow 松开并重新获得焦点。我不确定这是否是我的错误。
原始解决方案由本主题的作者 Blackwood 在下方发布,所有功劳归他们所有
谢谢。我正在寻找这个并且偶然发现了这个线程。
我正在寻找的应用程序使用 PyQt5,它虽然相似,但有足够的差异来破坏上面发布的代码。
我编辑了代码以使其适用于 PyQt5。它可以工作,但没有经过适当的错误测试
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
我在发布 PyQt5 答案时遇到了一些问题,所以我对 Blackwood 的代码进行了自己的重构,并且在不删除任何部分的情况下让它工作:
#
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_()