How to make an Angled arrow style border in PyQt5?

Question:

How to make an Angled arrow-type border in PyQt QFrame? In My code, I Have two QLabels and respective frames. My aim is to make an arrow shape border on right side of every QFrame.For clear-cut idea, attach a sample picture.

import sys
from PyQt5.QtWidgets import *

class Angle_Border(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Angle Border")

        self.lbl1 = QLabel("Python")
        self.lbl2 = QLabel("PyQt")

        self.frame1 = QFrame()
        self.frame1.setProperty("type","1")
        self.frame1.setFixedSize(200,50)
        self.frame1.setStyleSheet("background-color:red;color:white;"
                                  "font-family:Trebuchet MS;font-size: 15pt;text-align: center;"
                                  "border-top-right-radius:25px solid ; border-bottom-right-radius:25px solid ;")
        self.frame2 = QFrame()
        self.frame2.setFixedSize(200, 50)
        self.frame2.setStyleSheet("background-color:blue;color:white;"
                                  "font-family:Trebuchet MS;font-size: 15pt;text-align: center;"
                                  "border-top:1px solid transparent; border-bottom:1px solid  transparent;")
        self.frame_outer = QFrame()
        self.frame_outer.setFixedSize(800, 60)
        self.frame_outer.setStyleSheet("background-color:green;color:white;"
                                  "font-family:Trebuchet MS;font-size: 15pt;text-align: center;")

        self.frame1_layout = QHBoxLayout(self.frame1)
        self.frame2_layout = QHBoxLayout(self.frame2)
        self.frame_outer_layout = QHBoxLayout(self.frame_outer)
        self.frame_outer_layout.setContentsMargins(5,0,0,0)

        self.frame1_layout.addWidget(self.lbl1)
        self.frame2_layout.addWidget(self.lbl2)

        self.hbox = QHBoxLayout()
        self.layout = QHBoxLayout()
        self.hbox.addWidget(self.frame1)
        self.hbox.addWidget(self.frame2)
        self.hbox.addStretch()
        self.hbox.setSpacing(0)
        # self.layout.addLayout(self.hbox)
        self.frame_outer_layout.addLayout(self.hbox)
        self.layout.addWidget(self.frame_outer)

        self.setLayout(self.layout)

def main():
    app = QApplication(sys.argv)
    ex = Angle_Border()
    ex.show()
    sys.exit(app.exec_())

if __name__ == '__main__':
    main()

Sample Picture
enter image description here

Asked By: Bala

||

Answers:

It seems that this link can anwser your question. However, I adopt a python version for you.

import sys
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import QColor, QPainter, QPen, QPainterPath, QBrush

class Angle_Border(QWidget):
    def __init__(self, firstButtonX, firstButtonY, buttonWidth, buttonHeight, triangleWidth, labels, colors):
        super().__init__()
        self.firstButtonX = firstButtonX
        self.firstButtonY = firstButtonY
        self.buttonWidth = buttonWidth
        self.buttonHeight = buttonHeight
        self.triangleWidth = triangleWidth
        self.labels = labels
        self.colors = colors

        self.button_lists = []
        for i, text_i in enumerate(self.labels):
            button_i = QPushButton(text_i, self)
            self.button_lists.append(button_i)
            button_i.setGeometry(self.firstButtonX + (self.buttonWidth+self.triangleWidth)*i, self.firstButtonY,
                                 self.buttonWidth, self.buttonHeight)
            button_i.setStyleSheet("background-color: %s;border-style: outset;border-width: 0px;" % (QColor(self.colors[i]).name()))
            # button_i.setStyleSheet("border-style: outset;border-width: 0px;")

    def paintEvent(self, event):
        super().paintEvent(event)
        painter = QPainter(self)
        for i, button_i in enumerate(self.button_lists):
            x = button_i.pos().x()
            y = button_i.pos().y()
            w = button_i.width()
            h = button_i.height()

            r = QRect(x+w, y, self.triangleWidth, h)

            #
            #   _____p1
            #  |       p3
            #  |_____ /
            #        p2
            point3X = x + w + self.triangleWidth
            point3Y = y + h/2
            point1X = x + w
            point1Y = y
            point2X = x + w
            point2Y = y + h

            path = QPainterPath()
            path.moveTo(point1X, point1Y)
            path.lineTo(point2X, point2Y)
            path.lineTo(point3X, point3Y)

            painter.setPen(QPen(Qt.NoPen))
            if i != len(self.button_lists) - 1:
                painter.fillRect(r, QBrush(self.colors[i+1]))
            painter.fillPath(path, QBrush(self.colors[i]))

def main():
    app = QApplication(sys.argv)

    firstButtonX = 0
    firstButtonY = 0
    buttonWidth = 50
    buttonHeight = 30
    triangleWidth = 30
    labels = ["step1", "step2", "step3"]
    colors = [Qt.red, Qt.blue, Qt.yellow]

    ex = Angle_Border(firstButtonX, firstButtonY, buttonWidth, buttonHeight, triangleWidth, labels, colors)
    ex.show()
    sys.exit(app.exec_())

if __name__ == '__main__':
    main()

Which gives:

enter image description here

Answered By: hellohawaii

You can use QTabBar and override its paint event.

For better display of the last tab, we also override the size hint functions in order to have enough space to show the last arrow without clipping it or drawing over the text.

class ArrowTabBar(QTabBar):
    def sizeHint(self):
        hint = super().sizeHint()
        if self.count():
            hint.setWidth(hint.width() + hint.height() * .2)
        return hint

    def minimumTabSizeHint(self, index):
        hint = super().minimumTabSizeHint(index)
        if index == self.count() - 1:
            hint.setWidth(hint.width() + hint.height() * .2)
        return hint

    def tabSizeHint(self, index):
        hint = super().tabSizeHint(index)
        if index == self.count() - 1:
            hint.setWidth(hint.width() + hint.height() * .2)
        return hint

    def paintEvent(self, event):
        count = self.count()
        if not count:
            return

        qp = QPainter(self)
        qp.setRenderHint(qp.Antialiasing)
        bottom = self.height()
        midY = bottom // 2
        midX = midY / 2.5
        bottom -= 1
        palette = self.palette()
        textColor = palette.windowText().color()
        normal = palette.mid()
        current = palette.dark()

        for i in range(count):
            rect = self.tabRect(i)
            path = QPainterPath()
            x = rect.x()
            right = rect.right()
            if i:
                path.moveTo(x - midX, bottom)
                path.lineTo(x + midX, midY)
                path.lineTo(x - midX, 0)
            else:
                path.moveTo(x, bottom)
                path.lineTo(x, 0)
            path.lineTo(right - midX, 0)
            path.lineTo(right + midX, midY)
            path.lineTo(right - midX, bottom)
            if i == self.currentIndex():
                qp.setBrush(current)
            else:
                qp.setBrush(normal)
            qp.setPen(Qt.NoPen)
            qp.drawPath(path)
            qp.setPen(textColor)
            qp.drawText(rect, Qt.AlignCenter|Qt.TextShowMnemonic, 
                self.tabText(i))


app = QApplication([])
panel = ArrowTabBar()
for i in range(5):
    panel.addTab('Item {}'.format(i + 1))
panel.show()
app.exec()
Answered By: musicamante
import sys
from PyQt5.QtWidgets import QWidget,QHBoxLayout,QLabel,QFrame,QApplication,QSizePolicy
from PyQt5.QtCore import Qt

class MyFrame(QWidget):
    def __init__(self,base_color,top_color,width,edge,text,text_color):
        super().__init__()
        self.base_color = base_color
        self.top_color = top_color
        self.width = width
        self.edge = edge
        self.text = text
        self.text_color = text_color

        self.lbl = QLabel()
        self.lbl.setText(self.text)
        self.lbl.setFixedHeight(self.width*2)
        self.lbl.setMinimumWidth((QSizePolicy.MinimumExpanding)+100)
        self.lbl.setContentsMargins(0,0,0,0)
        self.lbl.setAlignment(Qt.AlignCenter)

        self.lbl.setStyleSheet(f"QLabel"
                               f"{{background-color: {self.base_color};"
                               f"color:{self.text_color};"
                               f"font-family:Trebuchet MS;"
                               f"font-size: 15pt;}}")
        self.frame_triangle = QFrame()
        self.frame_triangle.setFixedSize(self.width, self.width * 2)
        self.frame_triangle.setContentsMargins(0,0,0,0)
    
        self.hbox = QHBoxLayout()
        self.hbox.setSpacing(0)
        self.hbox.setContentsMargins(0,0,0,0)
        self.setLayout(self.hbox)

        if self.edge == "right":
            self.border = "border-left"
            self.hbox.addWidget(self.lbl)
            self.hbox.addWidget(self.frame_triangle)
        elif self.edge == "left":
            self.border = "border-right"
            self.hbox.addWidget(self.frame_triangle)
            self.hbox.addWidget(self.lbl)
        elif self.edge == "none":
            self.border = "border-right"
            self.hbox.addWidget(self.lbl)
            self.lbl.setMinimumWidth((QSizePolicy.MinimumExpanding) + 150)

        self.frame_triangle.setStyleSheet(f"QFrame"
                       f"{{background-color: {self.base_color};"
                       f"border-top:100px solid {self.top_color};"
                       f"{self.border}:100px solid {self.base_color};"
                       f"border-bottom:100px solid {self.top_color};"
                       f"}}")

class Main_Frame(QWidget):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Angled Frame")
        triangle_size = 50
        self.frame1 = MyFrame("lightgrey","green",triangle_size,"right","","lightgrey")
        self.frame2 = MyFrame("green","red",triangle_size,"right","Python","white")
        self.frame3 = MyFrame("red","blue",triangle_size,"right","PyQt5","white")
        self.frame4 = MyFrame("blue","yellow",triangle_size,"right","Java","white")
        self.frame5 = MyFrame("yellow","lightgrey",triangle_size,"right","ASP.Net","black")

        self.frame_overall = QFrame()
        self.frame_overall.setStyleSheet("background-color:lightgrey;")
        self.frame_overall.setSizePolicy(QSizePolicy.Minimum,QSizePolicy.Maximum)
        self.frame_overall_layout = QHBoxLayout(self.frame_overall)
        self.frame_overall_layout.setSpacing(0)

        # self.frame_overall_layout.addWidget(self.frame1)
        self.frame_overall_layout.addWidget(self.frame2)
        self.frame_overall_layout.addWidget(self.frame3)
        self.frame_overall_layout.addWidget(self.frame4)
        self.frame_overall_layout.addWidget(self.frame5)

        self.vbox = QHBoxLayout()
        self.vbox.setContentsMargins(0,0,0,0)

        self.vbox.setSpacing(0)
        self.vbox.addStretch()
        self.vbox.addWidget(self.frame_overall)
        self.vbox.addStretch()
        self.setLayout(self.vbox)

def main():
    app = QApplication(sys.argv)
    ex = Main_Frame()
    ex.show()
    sys.exit(app.exec_())

if __name__ == '__main__':
    main()

enter image description here

Answered By: Bala

Since the OP didn’t ask for user interaction (mouse or keyboard), a possible solution could use the existing features of Qt, specifically QSS (Qt Style Sheets).

While the currently previously accepted solution does follow that approach, it’s not very effective, most importantly because it’s basically "static", since it always requires knowing the color of the following item in order to define the "arrow" colors.
This not only forces the programmer to always consider the "sibling" items, but also makes extremely (and unnecessarily) complex the dynamic creation of such objects.

The solution is to always (partially) "redo" the layout and update the stylesheets with the necessary values, which consider the current size (which shouldn’t be hardcoded), the following item (if any) and carefully using the layout properties and "spacer" stylesheets based on the contents.

The following code uses a more abstract, dynamic approach, with basic functions that allow adding/insertion and removal of items. It still uses a similar QSS method, but, with almost the same "line count", it provides a simpler and much more intuitive approach, allowing item creation, deletion and modification with single function calls that are much easier to use.

A further benefit of this approach is that implementing "reverse" arrows is quite easy, and doesn’t break the logic of the item creation.

Considering all the above, you can create an actual class that just needs basic calls such as addItem() or removeItem().

from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *

class ArrowMenu(QWidget):
    vMargin = -1
    hMargin = -1
    def __init__(self, items=None, parent=None):
        super().__init__(parent)
        layout = QHBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)
        layout.addStretch()
        self.items = []
        if isinstance(items, dict):
            self.addItems(items.items())
        elif items is not None:
            self.addItems(items)

    def addItems(self, items):
        for item in items:
            if isinstance(item, str):
                self.addItem(item)
            else:
                self.addItem(*item)

    def addItem(self, text, background=None):
        self.insertItem(len(self.items), text, background)

    def insertItem(self, index, text, background=None):
        label = QLabel(text)
        if background is None:
            background = self.palette().window().color()
            background.setAlpha(0)
        else:
            background = QColor(background)

        # human eyes perceive "brightness" in different ways, let's compute
        # that value in order to decide a color that has sufficient contrast
        # with the background; see https://photo.stackexchange.com/q/10412
        r, g, b, a = background.getRgbF()
        brightness = r * .3 + g * .59 + b * .11
        foreground = 'black' if brightness >= .5 else 'white'

        label.setStyleSheet('color: {}; background: {};'.format(
            foreground, background.name(background.HexArgb)))

        layout = self.layout()
        if index < len(self.items):
            i = 0
            for _label, _spacer, _ in self.items:
                if i == index:
                    i += 1
                layout.insertWidget(i * 2, _label)
                layout.insertWidget(i * 2 + 1, _spacer)
                i += 1

        layout.insertWidget(index * 2, label)
        spacer = QWidget(objectName='menuArrow')
        layout.insertWidget(index * 2 + 1, spacer)
        self.items.insert(index, (label, spacer, background))
        self.updateItems()

    def removeItem(self, index):
        label, spacer, background = self.items.pop(index)
        label.deleteLater()
        spacer.deleteLater()
        layout = self.layout()
        for i, (label, spacer, _) in enumerate(self.items):
            layout.insertWidget(i * 2, label)
            layout.insertWidget(i * 2 + 1, spacer)
        self.updateItems()
        self.updateGeometry()

    def updateItems(self):
        if not self.items:
            return

        size = self.fontMetrics().height()
        if self.vMargin < 0:
            vSize = size * 2
        else:
            vSize = size + self.vMargin * 2
        spacing = vSize / 2
        self.setMinimumHeight(vSize)
        if self.hMargin >= 0:
            labelMargin = self.hMargin * 2
        else:
            labelMargin = size // 2

        it = iter(self.items)
        prevBackground = prevSpacer = None
        while True:
            try:
                label, spacer, background = next(it)
                label.setContentsMargins(labelMargin, 0, labelMargin, 0)
                spacer.setFixedWidth(spacing)

            except StopIteration:
                background = QColor()
                break

            finally:
                if prevBackground:
                    if background.isValid():
                        cssBackground = background.name(QColor.HexArgb)
                    else:
                        cssBackground = 'none'
                    if prevBackground.alpha():
                        prevBackground = prevBackground.name(QColor.HexArgb)
                    else:
                        mid = QColor(prevBackground)
                        mid.setAlphaF(.5)
                        prevBackground = '''
                            qlineargradient(x1:0, y1:0, x2:1, y2:0,
                            stop:0 {}, stop:1 {})
                        '''.format(
                            prevBackground.name(QColor.HexArgb), 
                            mid.name(QColor.HexArgb), 
                            )
                    prevSpacer.setStyleSheet('''
                        ArrowMenu > .QWidget#menuArrow {{
                            background: transparent;
                            border-top: {size}px solid {background};
                            border-bottom: {size}px solid {background};
                            border-left: {spacing}px solid {prevBackground};
                        }}
                    '''.format(
                            size=self.height() // 2, 
                            spacing=spacing, 
                            prevBackground=prevBackground, 
                            background=cssBackground
                    ))

                prevBackground = background
                prevSpacer = spacer

    def resizeEvent(self, event):
        self.updateItems()


if __name__ == '__main__':
    import sys
    app = QApplication(sys.argv)
    items = (
            ('Python', 'green'), 
            ('Will delete', 'chocolate'), 
            ('PyQt5', 'red'), 
            ('Java', 'blue'), 
            ('ASP.Net', 'yellow'), 
        )
    ex = ArrowMenu(items)
    ex.show()
    QTimer.singleShot(2000, lambda: ex.addItem('New item', 'aqua'))
    QTimer.singleShot(5000, lambda: ex.removeItem(1))
    sys.exit(app.exec_())

And here is the result:

screenshot of the example

Answered By: musicamante
Categories: questions Tags: , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.