|
|
@@ -1,4 +1,6 @@
|
|
|
-from PyQt5 import QtGui, QtCore, QtWidgets, QtWidgets
|
|
|
+from PyQt5 import QtGui, QtCore, QtWidgets
|
|
|
+from PyQt5.QtCore import pyqtSignal, pyqtSlot
|
|
|
+
|
|
|
from copy import copy
|
|
|
import re
|
|
|
import logging
|
|
|
@@ -550,6 +552,448 @@ class FCTab(QtWidgets.QTabWidget):
|
|
|
self.tabBar().setTabButton(currentIndex, QtWidgets.QTabBar.RightSide, None)
|
|
|
|
|
|
|
|
|
+class FCDetachableTab(QtWidgets.QTabWidget):
|
|
|
+ # From here: https://stackoverflow.com/questions/47267195/in-pyqt4-is-it-possible-to-detach-tabs-from-a-qtabwidget
|
|
|
+ def __init__(self, parent=None):
|
|
|
+
|
|
|
+ super().__init__()
|
|
|
+
|
|
|
+ self.tabBar = self.FCTabBar(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
|
|
|
+
|
|
|
+ self.setTabsClosable(True)
|
|
|
+ self.tabCloseRequested.connect(self.closeTab)
|
|
|
+
|
|
|
+
|
|
|
+ def deleteTab(self, currentIndex):
|
|
|
+ widget = self.widget(currentIndex)
|
|
|
+ if widget is not None:
|
|
|
+ widget.deleteLater()
|
|
|
+ self.removeTab(currentIndex)
|
|
|
+
|
|
|
+ def closeTab(self, currentIndex):
|
|
|
+ self.removeTab(currentIndex)
|
|
|
+
|
|
|
+ def protectTab(self, currentIndex):
|
|
|
+ # self.FCTabBar().setTabButton(currentIndex, QtWidgets.QTabBar.RightSide, None)
|
|
|
+ self.tabBar.setTabButton(currentIndex, QtWidgets.QTabBar.RightSide, None)
|
|
|
+
|
|
|
+ ##
|
|
|
+ # 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.FCDetachedTab(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 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 FCDetachedTab(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
|
|
|
+
|
|
|
+ class FCTabBar(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 = mcd imeData.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)
|
|
|
+
|
|
|
+
|
|
|
class VerticalScrollArea(QtWidgets.QScrollArea):
|
|
|
"""
|
|
|
This widget extends QtGui.QScrollArea to make a vertical-only
|