I am trying to make a Qlabel looks like modern chat bubbles in messengers (round rect with a triangular tip) like in this image:

enter image description here

I have managed to make the qlabel have one sharp edge but can't get how to make the tip. the problem was to insert a triangular path at the corner, the qlabel round rect and the text should be shifted in the opposite direction, but making this leads the text go out of the label area

Here is a sub-classed label with overridden paint events and resize events ( resize used in word wrapping which is outside my problem scope-maybe)> I removed some unnecessary code related to coloring, fonts,..etc

class chatLabel(QtWidgets.QLabel):
    def __init__(self,text):
        super(chatLabel, self).__init__(text)
        self.setContentsMargins(6,6,6,6)
        sizePolicy = QSizePolicy(QSizePolicy.Fixed,QSizePolicy.Expanding )
        sizePolicy.setHorizontalStretch(0)
        sizePolicy.setVerticalStretch(0)
        self.setSizePolicy(sizePolicy)
        self.color = QtGui.QColor("#333C43")

    def paintEvent(self, e):
        p = QtGui.QPainter(self)
        p.setRenderHint(QtGui.QPainter.Antialiasing, False)
        rect =  QtCore.QRectF(0,0,self.width()-1,self.height()-1)
        p.setPen(Qt.NoPen)
        path = QtGui.QPainterPath()
        path.setFillRule(Qt.WindingFill )
        path.addRoundedRect(rect, 15.0, 15.0)
        path.addRect(self.width()-13, 0, 13, 13)
        p.fillPath(path, self.color)

       super(chatLabel, self).paintEvent(e)

    def resizeEvent(self, e): #Due to a bug in Qt, we need this. ref:https://bugreports.qt.io/browse/QTBUG-37673
        #heightForWidth rely on minimumSize to evaulate, so reset it before
        self.setMinimumHeight( 0 )

        # define minimum height
        self.setMinimumHeight( self.heightForWidth( self.width() ) )
        if self.width()>256:
            self.setWordWrap(True)
            self.setMinimumWidth(128)

        super(chatLabel, self).resizeEvent(e)

This the result of the above sub-classed label

enter image description here

How can I reach the look I want? N.B: I am aware I can do it with images, but this requires scaling image (9-slice) according to text size

2 Answers

0
Mohamed Ibrahim On Best Solutions

I think I found a simple solution for my problem.

Since the problem was that if I made a tip at the right corner, a shift in the text is required to make the text contained inside the round rect (the bubble). We can make this shift by using padding in style sheets which will make the text shifted from the corner. So, the text will appear as it is included in the bubble.

Thank's to user9402680's answer and his code snippet, I added style sheet line to it to achieve the required effect.

from PyQt5.QtGui import *
from PyQt5.QtCore import *
from PyQt5.QtWidgets import *
import sys
class chatLabel(QLabel):
    def __init__(self,text):
        super(chatLabel, self).__init__(text)
        self.setContentsMargins(6,6,6,6)
        sizePolicy = QSizePolicy(QSizePolicy.Fixed,QSizePolicy.Expanding )
        sizePolicy.setHorizontalStretch(0)
        sizePolicy.setVerticalStretch(0)
        self.setSizePolicy(sizePolicy)
        self.color = QColor("#333C43")
        # 17 px margin from right (to make the text included in the bubble
        # 8  px margin from left. 
        self.setStyleSheet("QLabel{padding: 0px 8px 0px 17px;}")


    def paintEvent(self, e):
        p = QPainter(self)
        p.setRenderHint(QPainter.Antialiasing, False)
        #I changed this width from - 1 to - 16 because I can't see the result good.
        rect =  QRectF(0,0,self.width()- 16,self.height()-1)
        p.setPen(Qt.NoPen)
        path = QPainterPath()
        path.setFillRule(Qt.WindingFill )
        path.addRoundedRect(rect, 15.0, 15.0)
        #I deleted this object
#        path.addRect(self.width()-13, 0, 13, 13)

        linePath = QPainterPath()
        linePath.moveTo(rect.right() + rect.width()/6 , rect.top())
        linePath.lineTo(rect.right() - rect.width()/2, rect.bottom())
        linePath.lineTo(rect.right() , rect.top() - rect.height()/3)
#        linePath.lineTo(rect.right() - rect.width()/5, rect.top() - rect.height()/2)
        path = path.united(linePath)
        #cubic bezier curve, please try this , too.
#        cubicPath =QPainterPath()
#        cubicPath.moveTo(rect.right() - 20, rect.top())
#        cubicPath.cubicTo(rect.right() - 20, rect.top() + rect.height()/2, rect.right() , rect.top() , rect.right() + 15, rect.top())
#        path = path.united(cubicPath)

        p.fillPath(path, self.color)

        super(chatLabel, self).paintEvent(e)

    def resizeEvent(self, e): #Due to a bug in Qt, we need this. ref:https://bugreports.qt.io/browse/QTBUG-37673
        #heightForWidth rely on minimumSize to evaulate, so reset it before
        self.setMinimumHeight( 0 )

        # define minimum height
        self.setMinimumHeight( self.heightForWidth( self.width() ) )
        if self.width()>256:
            self.setWordWrap(True)
            self.setMinimumWidth(128)

        super(chatLabel, self).resizeEvent(e)
def main():
    try:
        app=QApplication([])
    except Exception as e:
        print(e)
    widget = chatLabel("This is the result!")
    widget.show()
    sys.exit(app.exec_())
if __name__ == "__main__":
    main()
from PyQt5.QtGui import *
from PyQt5.QtCore import *
from PyQt5.QtWidgets import *
import sys
class chatLabel(QLabel):
    def __init__(self,text):
        super(chatLabel, self).__init__(text)
        self.setContentsMargins(6,6,6,6)
        sizePolicy = QSizePolicy(QSizePolicy.Fixed,QSizePolicy.Expanding )
        sizePolicy.setHorizontalStretch(0)
        sizePolicy.setVerticalStretch(0)
        self.setSizePolicy(sizePolicy)
        self.color = QColor("#333C43")

    def paintEvent(self, e):
        p = QPainter(self)
        p.setRenderHint(QPainter.Antialiasing, False)
        #I changed this width from - 1 to - 16 because I can't see the result good.
        rect = QRectF(16,0,self.width()- 16,self.height()-1)
        p.setPen(Qt.NoPen)
        path = QPainterPath()
        path.setFillRule(Qt.WindingFill )
        path.addRoundedRect(rect, 15.0, 15.0)
        #I deleted this object
#        path.addRect(self.width()-13, 0, 13, 13)

        linePath = QPainterPath() linePath.moveTo(rect.left() - rect.width()/6 ,rect.top()) 
        linePath.lineTo(rect.left() + rect.width()/2, rect.bottom()) 
        linePath.lineTo(rect.left() , rect.top() - rect.height()/3)
#       linePath.lineTo(rect.right() - rect.width()/5, rect.top() - rect.height()/2)
        path = path.united(linePath)
        #cubic bezier curve, please try this , too.
#        cubicPath =QPainterPath()
#        cubicPath.moveTo(rect.right() - 20, rect.top())
#        cubicPath.cubicTo(rect.right() - 20, rect.top() + rect.height()/2, rect.right() , rect.top() , rect.right() + 15, rect.top())
#        path = path.united(cubicPath)

        p.fillPath(path, self.color)

        super(chatLabel, self).paintEvent(e)

    def resizeEvent(self, e): #Due to a bug in Qt, we need this. ref:https://bugreports.qt.io/browse/QTBUG-37673
        #heightForWidth rely on minimumSize to evaulate, so reset it before
        self.setMinimumHeight( 0 )

        # define minimum height
        self.setMinimumHeight( self.heightForWidth( self.width() ) )
        if self.width()>256:
            self.setWordWrap(True)
            self.setMinimumWidth(128)

        super(chatLabel, self).resizeEvent(e)
def main():
    try:
        app=QApplication([])
    except Exception as e:
        print(e)
    widget = chatLabel("This is the result!")
    widget.show()
    sys.exit(app.exec_())
if __name__ == "__main__":
    main()
1
user9402680 On

I have understood what you said as follows:

  1. You want to draw a tip triangle at the edge anywhere on the RoundRect.
  2. But the tip is out of the rectangle as it is,and then if you try to avoid this,if you expand the label size for viewng the tip, the texts on the rectangle are going to out of the rectangle vice versa. You want to avoid these phenomena.That is to say, you want to include the texts in the RoundRect all times and show the tip triangle simultaneously.
  3. According to this understanding, I retried to write the code.
  4. Please don't hesitate to ask me if you have some problems remained.

Explanation

You can't show the triangle tip and the texts simultaneously, because the size of the round rectangle ( I will call this as "bubble") is almost equal to the label size.So I tried to change it.

To change this, I calculated the size of bubble from each text. I made this function:

calc_textswidth(self, text, minimumwidth)

This calculates the width of texts every character. and return the length. if the length is more than the bubble width (label width - linepath length) , I inserted the "\n" for wrapping.

If you set setWrap(True), it becomes messy. Because this means the texts are going to wrap if the texts reach at the end of label.So I delete the method.

text = self.text() 
text = text.replace("\n", "")

For recalculating the position of texts every resizing, it is important to joint all texts as one string.And we calculate the all length of the string, we divide the texts every the length becomes more than the width of bubble.

We do it over and over again.

As the explanation, I have divided the size of label and bubble. The width of the bubbles are dicided on the basis of the length of texts.

P.S

The calculation is very verbose. Probably, it will become compact if you use list comprehension , and so on...

I hope this calculation is not a kind of bottle neck of this app...

If it remains something , please don't hesitate to ask me.

Update

As you say, my code is a pitfall of the line. I move the line edge to the centerpoint.

I think this is the best place for the top edge.


from PyQt5.QtGui import *
from PyQt5.QtCore import *
from PyQt5.QtWidgets import *
import sys
class chatLabel(QLabel):
    def __init__(self, text):
        super(chatLabel, self).__init__(text)
        self.setContentsMargins(6,6,6,6)
        sizePolicy = QSizePolicy(QSizePolicy.Fixed,QSizePolicy.Expanding )
        sizePolicy.setHorizontalStretch(0)
        sizePolicy.setVerticalStretch(0)
        self.setSizePolicy(sizePolicy)
        #I changed this color because I can't see the text good.
        self.color = QColor("#333C43")
        self.tip_length = 15
        self.coodinate_point = 10
        self.setText(text)
        self.initial_minimumwidth = 128
        self.setMinimumWidth(self.initial_minimumwidth)
        self.initial_maximumwidth = 128
        self.setMaximumWidth(self.initial_maximumwidth)
    def paintEvent(self, e):
        p = QPainter(self)
        p.setRenderHint(QPainter.Antialiasing, False)
        #I changed this width from - 1 to - 16 because I can't see the result good.
        rect =  QRectF(0,0,self.width()- self.tip_length,self.height()-1)
        p.setPen(Qt.NoPen)
        path = QPainterPath()
        path.setFillRule(Qt.WindingFill )
        path.addRoundedRect(rect, 15.0, 15.0)
        #I deleted this object
        #path.addRect(self.width()-13, 0, 13, 13)

        linePath = QPainterPath()
        linePath.moveTo(rect.right() + 15 , rect.top())       
        center = rect.center()
        linePath.lineTo(center.x()  , rect.bottom())
        linePath.lineTo(rect.right() , rect.top() - rect.height())

        path = path.united(linePath)


        p.fillPath(path, self.color)

        super(chatLabel, self).paintEvent(e)
    def checktextsinside(self, text):
        font = self.font()
        fontmetrics = QFontMetricsF(font)
        fontwidth = fontmetrics.width(text)
        return fontwidth
    def checkeachcharinside(self, text, minimumwidth):
        font = self.font()
        fontmetrics = QFontMetricsF(font)
        t_sum = 0
        t_join = ""
        chat_data = []
        for num, t in enumerate(text):   
            cw = fontmetrics.widthChar(t)
            t_sum += cw
            t_join += t        
            if t_sum > minimumwidth - self.tip_length :

                chat_data.append(t_join+"\n")
                t_sum = 0
                t_join = ""

        #append the final extra t_join
        chat_data.append(t_join)
        return t_sum, chat_data
    def resizeEvent(self, e): #Due to a bug in Qt, we need this. ref:https://bugreports.qt.io/browse/QTBUG-37673
        #heightForWidth rely on minimumSize to evaulate, so reset it before   
        if self.width()>256:
            self.setMinimumWidth(128)       
        text = self.text() 
        text = text.replace("\n", "")
        n_width, chat_data = self.checkeachcharinside(text, self.initial_minimumwidth - (self.tip_length + self.coodinate_point))        
        joint_text = ""
        for joint in chat_data:          
            joint_text += joint            
        self.setText(joint_text)
        self.setMinimumSize(QSize(n_width + self.tip_length, self.heightForWidth( self.width())))
        super(chatLabel, self).resizeEvent(e)
def main():
    try:
        app=QApplication([])
    except Exception as e:
        print(e)
    widget = chatLabel("This is the result typing!Please Don't wrap!Yaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")

    widget.show()
    sys.exit(app.exec_())
if __name__ == "__main__":
    main()