Search code examples
pythonpyqt5slideqtabbar

PyQt5 QTabBar paintEvent with tabs that can move


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?


Solution

  • 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.