PyQt5 QTabBar paintEvent with tabs that can move

864 views Asked by At

I would like to have a QTabBar with customised painting in the paintEvent(self,event) method, whilst maintaining the moving tabs animations / mechanics. I posted a question the other day about something similar, but it wasn't worded too well so I have heavily simplified the question with the following code:

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_()

there are some clear problems:

  • Dragging the tab to 'slide' it in the QTabBar is not smooth (it doens't glide) - it jumps to the next index.
  • The background tabs (non-selected tabs) don't glide into place once displaced - they jump into position.
  • When the tab is slid to the end of the tab bar (past the most right tab) and then let go of it doesn't glide back to the last index - it jumps there.
  • When sliding a tab, it stays in its original place and at the mouse cursor (in its dragging position) at the same time, and only when the mouse is released does the tab only show at the correct place (up until then it is also showing at the index it is originally from).

How can I modify the painting of a QTabBar with a QStyleOptionTab whilst maintaining all of the moving mechanics / animations of the tabs?

1

There are 1 answers

7
musicamante On BEST ANSWER

While it might seem a slightly simple widget, QTabBar is not, at least if you want to provide all of its features.

If you closely look at its source code, you'll find out that within the mouseMoveEvent() a private QMovableTabWidget is created whenever the drag distance is wide enough. That QWidget is a child of QTabBar that shows a QPixmap grab of the "moving" tab using the tab style option and following the mouse movements, while at the same moment that tab becomes invisible.

While your implementation might seem reasonable (note that I'm also referring to your original, now deleted, question), there are some important issues:

  • it doesn't account for the above "moving" child widget (in fact, with your code I can still see the original tab, even if that is that moving widget that's not actually moving since no call to the base implementation of mouseMoveEvent() is called);
  • it doesn't actually tabs;
  • it doesn't correctly process mouse events;

This is a complete implementation partially based on the C++ sources (I've tested it even with vertical tabs, and it seems to behave as it should):

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)

I strongly suggest you to carefully read and try to understand the above code (along with the source code), as I didn't comment everything I've done, and it's very important to understand what's happening if you really need to do further subclassing in the future.

Update

If you need to alter the appearance of the dragged tab while moving it, you need to update its pixmap. You can just store the QStyleOptionTab when you create it, and then update when necessary. In the following example the WindowText (note that QPalette.Foreground is obsolete) color is changed whenever the index of the tab is changed:

    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)

Another small suggestion: while you can obviously use the indentation style you like, when sharing your code on public spaces like StackOverflow it's always better to stick to common conventions, so I suggest you to always provide your code with 4-spaces indentations; also, remember that there should always be a space after each comma separated variable, as it dramatically improves readability.