-2

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?

SamG101
  • 488
  • 1
  • 7
  • 18
  • 3
    Please don't remove questions just to post them again, it's considered annoying (and might even lead to post removal). Be patient, as your question might need some time until it gets answered, especially with complex issues as yours (QTabBar drawing is *not* simple as it might seem): in fact I was in the process of answering you, but the answer requires some time. If you want your question to receive more attention and attract users, consider starting a [bounty](https://stackoverflow.com/help/bounty). – musicamante Oct 13 '20 at 20:17
  • OK I won't remove questions again, my bad. Sorry for the inconvenience. Will do, thanks! – SamG101 Oct 13 '20 at 21:21

1 Answers1

2

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.

musicamante
  • 41,230
  • 6
  • 33
  • 58
  • Wow thanks! This works amazingly, although I have come across one issue: in the `mouseMoveEvent` method, in order to paint the moving tab, it takes a pixmap of a newly created `QStyleOptionTab` - this means that any painting modifications done in the `paintEvent` method do not appear on the tab being moved... – SamG101 Oct 14 '20 at 16:12
  • ... For example, if the line of code: `option.palette.setColor(QPalette.Foreground,QColor("red"))` is inserted into the `paintEvent` method, directly above the line `painter.drawControl(QStyle.CE_TabBarTab, tabOption)`, then the font color on the tabs is red, but for the tab being dragged, the tab text color reverts to being black again. Is there any way to be able to take the pixmap of the painted tab as opposed to a default looking tab to paint the moving tab? Thanks! – SamG101 Oct 14 '20 at 16:12
  • If you want to change the appearence of the moving tab, you could create a reference the QStyleOptionTab used when you create the moving tab, then create a new pixmap based on it with the modified palette. – musicamante Oct 14 '20 at 16:18
  • This is incredibly helpful, thanks. In Qt6 (6.2.4) I think there's a bug where offsetting QStyleOptionTab::rect like this doesn't affect the text position, so text isn't rendered in the correct position in the pixmap. Offsetting the painter instead worked for me. – rainbowgoblin Apr 01 '22 at 17:25
  • @rainbowgoblin So in Qt6 the tab is properly painted but the text is not aligned with it? That seems strange. Can you provide a screenshot of the result? I cannot test it right now. – musicamante Apr 01 '22 at 17:31
  • @musicamante So it seems like I'm only half right: I think this is a stylesheet problem (I haven't checked if it affects Qt5). Here are screenshots before and while moving a tab: https://imgur.com/a/RsE2xua If I use QTabBar out of the box I don't see the same problem. – rainbowgoblin Apr 02 '22 at 20:09
  • Please do check it on Qt5 (possibly on the same system, which seems to be macOS, is it?), and also with other styles (see the list of available styles with `QStyleFactory.keys()`), and consider analyzing the QSS. Also, does this happen with the updated code at the bottom of the answer? – musicamante Apr 02 '22 at 20:17