PyQt5 QTabBar paintEvent 带有可以移动的标签

PyQt5 QTabBar paintEvent with tabs that can move

我想要 QTabBarpaintEvent(self,event) 方法中进行自定义绘画,同时保持移动标签的动画/机制。前几天我发布了一个关于类似问题的问题,但措辞不太好所以我用以下代码大大简化了问题:

from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
from PyQt5.QtTest import QTest
import sys

class MainWindow(QMainWindow):
  def __init__(self,parent=None,*args,**kwargs):
    QMainWindow.__init__(self,parent,*args,**kwargs)

    self.tabs = QTabWidget(self)
    self.tabs.setTabBar(TabBar(self.tabs))
    self.tabs.setMovable(True)

    for color in ["red","orange","yellow","lime","green","cyan","blue","purple","violet","magenta"]:
      title = color
      widget = QWidget(styleSheet="background-color:%s" % color)

      pixmap = QPixmap(8,8)
      pixmap.fill(QColor(color))
      icon = QIcon(pixmap)

      self.tabs.addTab(widget,icon,title)

    self.setCentralWidget(self.tabs)
    self.showMaximized()

class TabBar(QTabBar):
  def __init__(self,parent,*args,**kwargs):
    QTabBar.__init__(self,parent,*args,**kwargs)

  def paintEvent(self,event):
    painter = QStylePainter(self)
    
    option  = QStyleOptionTab()
    for i in range(self.count()):
      self.initStyleOption(option,i)

      #Customise 'option' here
      
      painter.drawControl(QStyle.CE_TabBarTab,option)

  def tabSizeHint(self,index):
    return QSize(112,48)

def exceptHook(e,v,t):
  sys.__excepthook__(e,v,t)

if __name__ == "__main__":
  sys.excepthook = exceptHook
  application = QApplication(sys.argv)
  mainwindow = MainWindow()
  application.exec_()

有一些明显的问题:

我如何修改 QTabBarQStyleOptionTab 的绘画,同时保持选项卡的所有移动机制/动画?

虽然 QTabBar 似乎是一个稍微简单的小部件,但它并非如此,至少如果您想提供它的所有功能的话。

如果你仔细查看它的源代码,你会发现在 mouseMoveEvent() 中,只要拖动距离足够宽,就会创建一个 private QMovableTabWidget。该 QWidget 是 QTabBar 的子项,它使用选项卡样式选项并跟随鼠标移动显示“移动”选项卡的 QPixmap grab,同时该选项卡变得不可见。

虽然您的实施看起来合理(请注意,我也指的是您的 original, now deleted, question),但存在一些重要问题:

  • 它不考虑上面的“移动”子窗口小部件(事实上,使用您的代码我仍然可以看到原始选项卡,即使那是 that 移动窗口小部件这实际上并没有移动,因为没有调用 mouseMoveEvent() 的基本实现;
  • 实际上 标签;
  • 它没有正确处理鼠标事件;

这是一个部分基于 C++ 源代码的完整实现(我已经用垂直制表符对其进行了测试,它似乎表现得像它应该的那样):

class TabBar(QTabBar):
    class MovingTab(QWidget):
        '''
        A private QWidget that paints the current moving tab
        '''
        def setPixmap(self, pixmap):
            self.pixmap = pixmap
            self.update()

        def paintEvent(self, event):
            qp = QPainter(self)
            qp.drawPixmap(0, 0, self.pixmap)

    def __init__(self,parent, *args, **kwargs):
        QTabBar.__init__(self,parent, *args, **kwargs)
        self.movingTab = None
        self.isMoving = False
        self.animations = {}
        self.pressedIndex = -1

    def isVertical(self):
        return self.shape() in (
            self.RoundedWest, 
            self.RoundedEast, 
            self.TriangularWest, 
            self.TriangularEast)

    def createAnimation(self, start, stop):
        animation = QVariantAnimation()
        animation.setStartValue(start)
        animation.setEndValue(stop)
        animation.setEasingCurve(QEasingCurve.InOutQuad)            
        def removeAni():
            for k, v in self.animations.items():
                if v == animation:
                    self.animations.pop(k)
                    animation.deleteLater()
                    break
        animation.finished.connect(removeAni)
        animation.valueChanged.connect(self.update)
        animation.start()
        return animation

    def layoutTab(self, overIndex):
        oldIndex = self.pressedIndex
        self.pressedIndex = overIndex
        if overIndex in self.animations:
            # if the animation exists, move its key to the swapped index value
            self.animations[oldIndex] = self.animations.pop(overIndex)
        else:
            start = self.tabRect(overIndex).topLeft()
            stop = self.tabRect(oldIndex).topLeft()
            self.animations[oldIndex] = self.createAnimation(start, stop)
        self.moveTab(oldIndex, overIndex)

    def finishedMovingTab(self):
        self.movingTab.deleteLater()
        self.movingTab = None
        self.pressedIndex = -1
        self.update()

    # reimplemented functions

    def tabSizeHint(self, i):
        return QSize(112, 48)

    def mousePressEvent(self, event):
        super().mousePressEvent(event)
        if event.button() == Qt.LeftButton:
            self.pressedIndex = self.tabAt(event.pos())
            if self.pressedIndex < 0:
                return
            self.startPos = event.pos()

    def mouseMoveEvent(self,event):
        if not event.buttons() & Qt.LeftButton or self.pressedIndex < 0:
            super().mouseMoveEvent(event)
        else:
            delta = event.pos() - self.startPos
            if not self.isMoving and delta.manhattanLength() < QApplication.startDragDistance():
                # ignore the movement as it's too small to be considered a drag
                return

            if not self.movingTab:
                # create a private widget that appears as the current (moving) tab
                tabRect = self.tabRect(self.pressedIndex)
                overlap = self.style().pixelMetric(
                    QStyle.PM_TabBarTabOverlap, None, self)
                tabRect.adjust(-overlap, 0, overlap, 0)
                pm = QPixmap(tabRect.size())
                pm.fill(Qt.transparent)
                qp = QStylePainter(pm, self)
                opt = QStyleOptionTab()
                self.initStyleOption(opt, self.pressedIndex)
                if self.isVertical():
                    opt.rect.moveTopLeft(QPoint(0, overlap))
                else:
                    opt.rect.moveTopLeft(QPoint(overlap, 0))
                opt.position = opt.OnlyOneTab
                qp.drawControl(QStyle.CE_TabBarTab, opt)
                qp.end()
                self.movingTab = self.MovingTab(self)
                self.movingTab.setPixmap(pm)
                self.movingTab.setGeometry(tabRect)
                self.movingTab.show()

            self.isMoving = True
            self.startPos = event.pos()
            isVertical = self.isVertical()
            startRect = self.tabRect(self.pressedIndex)
            if isVertical:
                delta = delta.y()
                translate = QPoint(0, delta)
                startRect.moveTop(startRect.y() + delta)
            else:
                delta = delta.x()
                translate = QPoint(delta, 0)
                startRect.moveLeft(startRect.x() + delta)

            movingRect = self.movingTab.geometry()
            movingRect.translate(translate)
            self.movingTab.setGeometry(movingRect)

            if delta < 0:
                overIndex = self.tabAt(startRect.topLeft())
            else:
                if isVertical:
                    overIndex = self.tabAt(startRect.bottomLeft())
                else:
                    overIndex = self.tabAt(startRect.topRight())
            if overIndex < 0:
                return

            # if the target tab is valid, move the current whenever its position 
            # is over the half of its size
            overRect = self.tabRect(overIndex)
            if isVertical:
                if ((overIndex < self.pressedIndex and movingRect.top() < overRect.center().y()) or
                    (overIndex > self.pressedIndex and movingRect.bottom() > overRect.center().y())):
                        self.layoutTab(overIndex)
            elif ((overIndex < self.pressedIndex and movingRect.left() < overRect.center().x()) or
                (overIndex > self.pressedIndex and movingRect.right() > overRect.center().x())):
                    self.layoutTab(overIndex)

    def mouseReleaseEvent(self,event):
        super().mouseReleaseEvent(event)
        if self.movingTab:
            if self.pressedIndex > 0:
                animation = self.createAnimation(
                    self.movingTab.geometry().topLeft(), 
                    self.tabRect(self.pressedIndex).topLeft()
                )
                # restore the position faster than the default 250ms
                animation.setDuration(80)
                animation.finished.connect(self.finishedMovingTab)
                animation.valueChanged.connect(self.movingTab.move)
            else:
                self.finishedMovingTab()
        else:
            self.pressedIndex = -1
        self.isMoving = False
        self.update()

    def paintEvent(self, event):
        if self.pressedIndex < 0:
            super().paintEvent(event)
            return
        painter = QStylePainter(self)
        tabOption = QStyleOptionTab()
        for i in range(self.count()):
            if i == self.pressedIndex and self.isMoving:
                continue
            self.initStyleOption(tabOption, i)
            if i in self.animations:
                tabOption.rect.moveTopLeft(self.animations[i].currentValue())
            painter.drawControl(QStyle.CE_TabBarTab, tabOption)

强烈建议您仔细阅读并尝试理解上面的代码(连同source code),因为我没有评论我的所有内容完成了,如果你真的需要在未来做进一步的子类化,了解发生了什么是非常重要的。

更新

如果您需要在移动时改变拖动选项卡的外观,您需要更新它的像素图。您可以在创建 QStyleOptionTab 时只存储它,然后在需要时更新。在下面的示例中,只要更改选项卡的索引,WindowText(请注意 QPalette.Foreground 已过时)颜色就会更改:

    def mouseMoveEvent(self,event):
        # ...
            if not self.movingTab:
                # ...
                self.movingOption = opt

    def layoutTab(self, overIndex):
        # ...
        self.moveTab(oldIndex, overIndex)
        pm = QPixmap(self.movingTab.pixmap.size())
        pm.fill(Qt.transparent)
        qp = QStylePainter(pm, self)
        self.movingOption.palette.setColor(QPalette.WindowText, <someColor>)
        qp.drawControl(QStyle.CE_TabBarTab, self.movingOption)
        qp.end()
        self.movingTab.setPixmap(pm)

另一个小建议:虽然您显然可以使用自己喜欢的缩进样式,但在 public spaces(如 Whosebug)上共享您的代码时,最好坚持使用常用的缩进样式约定,因此我建议您始终为代码提供 4-spaces 缩进;另外,请记住,在每个逗号分隔的变量之后应该始终有一个 space,因为它显着提高了可读性。