Cloud around the text in QTextEdit

Hi guys I’m finishing the graphical interface of my chat app in Qt + Python, the messages are printed in a QTextEdit and to embellish them I would like to add the speech bubble as telegram/whatsapp around, advice for doing it?

1 Like

Hey @Guasco welcome to the forum.

It might be possible to do this in a QTextEdit but I wouldn’t recommend it. You’ll need to isolate messages as objects and lose any of the simplicity of working with the text block.

I think a better approach would be to use a QListView with each chat entry being a separate item – that fits better with the model of text messages than a single block of text. You can then use a delegate to draw the text bubble around each one.

Have a look at the todo example in this tutorial. You can effectively use the same, just drop the todo status. For drawing you’ll need to use a delegate to paint it. I can put together an example of that if you’re not familiar with it?

Yes thank you, you would be very helpful

Here you go. The following shows a QListView with a series of messages, each with a bubble. The bubbles resize to the size of the message.

The basic UI has a text box and two arrows (just for demo purposes) which allows you to “send” and “receive” a message, drawing the appropriate bubble.

First the code (explanation follows) –

import sys

from PyQt5.QtCore import QAbstractListModel, QMargins, QPoint, QSize, Qt
from PyQt5.QtGui import QColor, QFontMetrics

# from PyQt5.QtGui import
from PyQt5.QtWidgets import (
    QApplication,
    QLineEdit,
    QListView,
    QMainWindow,
    QPushButton,
    QVBoxLayout,
    QWidget,
)

USER_ME = 0
USER_THEM = 1

BUBBLE_COLORS = {USER_ME: "#90caf9", USER_THEM: "#a5d6a7"}

BUBBLE_PADDING = QMargins(15, 5, 15, 5)
TEXT_PADDING = QMargins(25, 15, 25, 15)


class MessageDelegate(QStyledItemDelegate):
    """
    Draws each message.
    """

    def paint(self, painter, option, index):
        # Retrieve the user,message uple from our model.data method.
        user, text = index.model().data(index, Qt.DisplayRole)

        # option.rect contains our item dimensions. We need to pad it a bit
        # to give us space from the edge to draw our shape.

        bubblerect = option.rect.marginsRemoved(BUBBLE_PADDING)
        textrect = option.rect.marginsRemoved(TEXT_PADDING)

        # draw the bubble, changing color + arrow position depending on who
        # sent the message. the bubble is a rounded rect, with a triangle in
        # the edge.
        painter.setPen(Qt.NoPen)
        color = QColor(BUBBLE_COLORS[user])
        painter.setBrush(color)
        painter.drawRoundedRect(bubblerect, 10, 10)

        # draw the triangle bubble-pointer, starting from

        if user == USER_ME:
            p1 = bubblerect.topRight()
        else:
            p1 = bubblerect.topLeft()
        painter.drawPolygon(p1 + QPoint(-20, 0), p1 + QPoint(20, 0), p1 + QPoint(0, 20))

        # draw the text
        painter.setPen(Qt.black)
        painter.drawText(textrect, Qt.TextWordWrap, text)

    def sizeHint(self, option, index):
        _, text = index.model().data(index, Qt.DisplayRole)
        # Calculate the dimensions the text will require.
        metrics = QApplication.fontMetrics()
        rect = option.rect.marginsRemoved(TEXT_PADDING)
        rect = metrics.boundingRect(rect, Qt.TextWordWrap, text)
        rect = rect.marginsAdded(TEXT_PADDING)  # Re add padding for item size.
        return rect.size()


class MessageModel(QAbstractListModel):
    def __init__(self, *args, **kwargs):
        super(MessageModel, self).__init__(*args, **kwargs)
        self.messages = []

    def data(self, index, role):
        if role == Qt.DisplayRole:
            # Here we pass the delegate the user, message tuple.
            return self.messages[index.row()]

    def rowCount(self, index):
        return len(self.messages)

    def add_message(self, who, text):
        """
        Add an message to our message list, getting the text from the QLineEdit
        """
        if text:  # Don't add empty strings.
            # Access the list via the model.
            self.messages.append((who, text))
            # Trigger refresh.
            self.layoutChanged.emit()


class MainWindow(QMainWindow):
    def __init__(self):
        super(MainWindow, self).__init__()

        # Layout the UI
        l = QVBoxLayout()

        self.message_input = QLineEdit("Enter message here")

        # Buttons for from/to messages.
        self.btn1 = QPushButton("<")
        self.btn2 = QPushButton(">")

        self.messages = QListView()
        # Use our delegate to draw items in this view.
        self.messages.setItemDelegate(MessageDelegate())

        self.model = MessageModel()
        self.messages.setModel(self.model)

        self.btn1.pressed.connect(self.message_to)
        self.btn2.pressed.connect(self.message_from)

        l.addWidget(self.messages)
        l.addWidget(self.message_input)
        l.addWidget(self.btn1)
        l.addWidget(self.btn2)

        self.w = QWidget()
        self.w.setLayout(l)
        self.setCentralWidget(self.w)

    def message_to(self):
        self.model.add_message(USER_ME, self.message_input.text())

    def message_from(self):
        self.model.add_message(USER_THEM, self.message_input.text())


app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()

This is built on the Qt Model Views, if you’re not familiar with that it might be worth having a look at the pyqt5 model views tutorials. I used the “todo” example there as the basis for this.

The addition is to implement the QStyleItemDelegate. This allows us to draw our own custom widget view for each item in the list. This is where we draw our text and the bubbles. See this introduction to QPainter if you’re not familiar with that.

The tricky part here is we need to know the size of the text in order to draw the bubble. That’s handled on the delegate .sizeHint which uses font metrics to determine the size of the box. It’s a bit faffy.

The data is stored in a list of tuples with user, message and the user is either 0 or 1 for “me” or “them”. You can push a message into the chat using add_message.

You should be able to drop this into your application, hook up the add_message method and have it work, but let me know if you have any problems.

Sorry I don’t follow, do you mean resize? Have you replaced your text edit with the list view & model?

Can you post a screenshot/the code so I can see what’s happening.

Can I send you the zip file? I set up various images and stylesheets

Sure thing, zip would be fine.

In your code your main window subclasses from the delegate, e.g.

class ChatApp(QMainWindow, __richiamo_pubnub, MessageDelegate):

…you don’t need to do that. You’re creating the delegate object and passing it to the view (further down).

Either way, your example works for me & the chat bubbles resize to the text.

I am sending you the zip file along with a screenshot of what happens when the message gets long, but could you give me some advice on how to align the other message when another user is sending the messages? Thanks for your availability (I sent the email to codereview@learnpyqt.com)

Thanks, got it. The problem is the Qt.TextWordWrap in the code will only break between words (spaces) so in the example you have a single block no breaks it won’t wrap. There is another flag Qt.TextWrapAnywhere but that will break in the middle of words.

To wrap between words and only wrap in words when there is no space, you need to use QTextOption with QTextOption.WrapAtWordBoundaryOrAnywhere. To use this we need to change the sizeHint method a bit as without a painter we need to create a QTextDocument to do the calculation.

Working code below …the only changes are at the bottom of the .paint method and the sizeHint.

import sys

from PyQt5.QtCore import QAbstractListModel, QMargins, QPoint, QRectF, QSize, Qt
from PyQt5.QtGui import QColor, QPainter, QTextDocument, QTextOption

# from PyQt5.QtGui import
from PyQt5.QtWidgets import (
    QApplication,
    QLineEdit,
    QListView,
    QMainWindow,
    QPushButton,
    QStyledItemDelegate,
    QVBoxLayout,
    QWidget,
)

USER_ME = 0
USER_THEM = 1

BUBBLE_COLORS = {USER_ME: "#90caf9", USER_THEM: "#a5d6a7"}

BUBBLE_PADDING = QMargins(15, 5, 15, 5)
TEXT_PADDING = QMargins(25, 15, 25, 15)


class MessageDelegate(QStyledItemDelegate):
    """
    Draws each message.
    """

    def paint(self, painter, option, index):
        # Retrieve the user,message uple from our model.data method.
        user, text = index.model().data(index, Qt.DisplayRole)

        # option.rect contains our item dimensions. We need to pad it a bit
        # to give us space from the edge to draw our shape.

        bubblerect = option.rect.marginsRemoved(BUBBLE_PADDING)
        textrect = option.rect.marginsRemoved(TEXT_PADDING)

        # draw the bubble, changing color + arrow position depending on who
        # sent the message. the bubble is a rounded rect, with a triangle in
        # the edge.
        painter.setPen(Qt.NoPen)
        color = QColor(BUBBLE_COLORS[user])
        painter.setBrush(color)
        painter.drawRoundedRect(bubblerect, 10, 10)

        # draw the triangle bubble-pointer, starting from the top left/right.
        if user == USER_ME:
            p1 = bubblerect.topRight()
        else:
            p1 = bubblerect.topLeft()
        painter.drawPolygon(p1 + QPoint(-20, 0), p1 + QPoint(20, 0), p1 + QPoint(0, 20))

        toption = QTextOption()
        toption.setWrapMode(QTextOption.WrapAtWordBoundaryOrAnywhere)

        # draw the text
        painter.setPen(Qt.black)
        painter.drawText(QRectF(textrect), text, toption)

    def sizeHint(self, option, index):
        _, text = index.model().data(index, Qt.DisplayRole)
        textrect = option.rect.marginsRemoved(TEXT_PADDING)

        toption = QTextOption()
        toption.setWrapMode(QTextOption.WrapAtWordBoundaryOrAnywhere)

        doc = QTextDocument(text)
        doc.setTextWidth(textrect.width())
        doc.setDefaultTextOption(toption)
        doc.setDocumentMargin(0)

        textrect.setHeight(doc.size().height())
        textrect = textrect.marginsAdded(TEXT_PADDING)
        return textrect.size()


class MessageModel(QAbstractListModel):
    def __init__(self, *args, **kwargs):
        super(MessageModel, self).__init__(*args, **kwargs)
        self.messages = []

    def data(self, index, role):
        if role == Qt.DisplayRole:
            # Here we pass the delegate the user, message tuple.
            return self.messages[index.row()]

    def rowCount(self, index):
        return len(self.messages)

    def add_message(self, who, text):
        """
        Add an message to our message list, getting the text from the QLineEdit
        """
        if text:  # Don't add empty strings.
            # Access the list via the model.
            self.messages.append((who, text))
            # Trigger refresh.
            self.layoutChanged.emit()


class MainWindow(QMainWindow):
    def __init__(self):
        super(MainWindow, self).__init__()

        # Layout the UI
        l = QVBoxLayout()

        self.message_input = QLineEdit("Enter message here")

        # Buttons for from/to messages.
        self.btn1 = QPushButton("<")
        self.btn2 = QPushButton(">")

        self.messages = QListView()
        # Use our delegate to draw items in this view.
        self.messages.setItemDelegate(MessageDelegate())

        self.model = MessageModel()
        self.messages.setModel(self.model)

        self.btn1.pressed.connect(self.message_to)
        self.btn2.pressed.connect(self.message_from)

        l.addWidget(self.messages)
        l.addWidget(self.message_input)
        l.addWidget(self.btn1)
        l.addWidget(self.btn2)

        self.w = QWidget()
        self.w.setLayout(l)
        self.setCentralWidget(self.w)

    def message_to(self):
        self.model.add_message(USER_ME, self.message_input.text())

    def message_from(self):
        self.model.add_message(USER_THEM, self.message_input.text())


app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()



Edit: fix the paint method to also use QTextDocument, e.g.

        # draw the text
        toption = QTextOption()
        toption.setWrapMode(QTextOption.WrapAtWordBoundaryOrAnywhere)

        doc = QTextDocument(text)
        doc.setTextWidth(textrect.width())
        doc.setDefaultTextOption(toption)
        doc.setDocumentMargin(0)

        painter.translate(textrect.topLeft())
        doc.drawContents(painter)
        painter.restore()

See below for the complete code.

1 Like

now it fits correctly, but there is a small detail, some letters remain cut, I send you a screenshot

Yeah, I saw the same issue – looking into it :slight_smile: …seems to be some issue between the calculation and the actual draw, maybe the width is not correct.

yes probably, however I am trying to make the message appear on the left when it is sent by another user, can you give me some advice?

The mismatch was down to the different fonts/layout of the text in each. So I’ve updated it here to use the QTextDocument in the painter too. This also adds translating the bubbles side to side (just offsets the painter). Note that you also have to reduce the size of the bubbles and text blocks to make this work.

import sys

from PyQt5.QtCore import QAbstractListModel, QMargins, QPoint, QRectF, QSize, Qt
from PyQt5.QtGui import QColor, QFont, QPainter, QTextDocument, QTextOption

# from PyQt5.QtGui import
from PyQt5.QtWidgets import (
    QApplication,
    QLineEdit,
    QListView,
    QMainWindow,
    QPushButton,
    QStyledItemDelegate,
    QVBoxLayout,
    QWidget,
)

USER_ME = 0
USER_THEM = 1

BUBBLE_COLORS = {USER_ME: "#90caf9", USER_THEM: "#a5d6a7"}
USER_TRANSLATE = {USER_ME: QPoint(20, 0), USER_THEM: QPoint(0, 0)}

BUBBLE_PADDING = QMargins(15, 5, 35, 5)
TEXT_PADDING = QMargins(25, 15, 45, 15)


class MessageDelegate(QStyledItemDelegate):
    """
    Draws each message.
    """

    _font = None

    def paint(self, painter, option, index):
        painter.save()
        # Retrieve the user,message uple from our model.data method.
        user, text = index.model().data(index, Qt.DisplayRole)

        trans = USER_TRANSLATE[user]
        painter.translate(trans)

        # option.rect contains our item dimensions. We need to pad it a bit
        # to give us space from the edge to draw our shape.
        bubblerect = option.rect.marginsRemoved(BUBBLE_PADDING)
        textrect = option.rect.marginsRemoved(TEXT_PADDING)

        # draw the bubble, changing color + arrow position depending on who
        # sent the message. the bubble is a rounded rect, with a triangle in
        # the edge.
        painter.setPen(Qt.NoPen)
        color = QColor(BUBBLE_COLORS[user])
        painter.setBrush(color)
        painter.drawRoundedRect(bubblerect, 10, 10)

        # draw the triangle bubble-pointer, starting from the top left/right.
        if user == USER_ME:
            p1 = bubblerect.topRight()
        else:
            p1 = bubblerect.topLeft()
        painter.drawPolygon(p1 + QPoint(-20, 0), p1 + QPoint(20, 0), p1 + QPoint(0, 20))

        toption = QTextOption()
        toption.setWrapMode(QTextOption.WrapAtWordBoundaryOrAnywhere)

        # draw the text
        doc = QTextDocument(text)
        doc.setTextWidth(textrect.width())
        doc.setDefaultTextOption(toption)
        doc.setDocumentMargin(0)

        painter.translate(textrect.topLeft())
        doc.drawContents(painter)
        painter.restore()

    def sizeHint(self, option, index):
        _, text = index.model().data(index, Qt.DisplayRole)
        textrect = option.rect.marginsRemoved(TEXT_PADDING)

        toption = QTextOption()
        toption.setWrapMode(QTextOption.WrapAtWordBoundaryOrAnywhere)

        doc = QTextDocument(text)
        doc.setTextWidth(textrect.width())
        doc.setDefaultTextOption(toption)
        doc.setDocumentMargin(0)

        textrect.setHeight(doc.size().height())
        textrect = textrect.marginsAdded(TEXT_PADDING)
        return textrect.size()


class MessageModel(QAbstractListModel):
    def __init__(self, *args, **kwargs):
        super(MessageModel, self).__init__(*args, **kwargs)
        self.messages = []

    def data(self, index, role):
        if role == Qt.DisplayRole:
            # Here we pass the delegate the user, message tuple.
            return self.messages[index.row()]

    def setData(self, index, role, value):
        self._size[index.row()]

    def rowCount(self, index):
        return len(self.messages)

    def add_message(self, who, text):
        """
        Add an message to our message list, getting the text from the QLineEdit
        """
        if text:  # Don't add empty strings.
            # Access the list via the model.
            self.messages.append((who, text))
            # Trigger refresh.
            self.layoutChanged.emit()


class MainWindow(QMainWindow):
    def __init__(self):
        super(MainWindow, self).__init__()

        # Layout the UI
        l = QVBoxLayout()

        self.message_input = QLineEdit("Enter message here")

        # Buttons for from/to messages.
        self.btn1 = QPushButton("<")
        self.btn2 = QPushButton(">")

        self.messages = QListView()
        self.messages.setResizeMode(QListView.Adjust)
        # Use our delegate to draw items in this view.
        self.messages.setItemDelegate(MessageDelegate())

        self.model = MessageModel()
        self.messages.setModel(self.model)

        self.btn1.pressed.connect(self.message_to)
        self.btn2.pressed.connect(self.message_from)

        l.addWidget(self.messages)
        l.addWidget(self.message_input)
        l.addWidget(self.btn1)
        l.addWidget(self.btn2)

        self.w = QWidget()
        self.w.setLayout(l)
        self.setCentralWidget(self.w)

    def resizeEvent(self, e):
        self.model.layoutChanged.emit()

    def message_to(self):
        self.model.add_message(USER_ME, self.message_input.text())

    def message_from(self):
        self.model.add_message(USER_THEM, self.message_input.text())


app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()

1 Like

I sent you an email, when you can, take a look at it

Hey @Guasco the problem is where you define your constants at the top of the file.

io = 0
altri = 0

…they’re both set to 0 so are indistinguishable! Should be …

io = 0
altri = 1

The actual values don’t matter, they just need to be different as we use them to check elsewhere.

yes of course I have already tried it but it has simply made the messages appear on the left, if you can we can connect and check together by chatting

Can you try explain more clearly what you want, or take a screenshot with that is wrong?

In the zip you sent the BUBBLE_PADDING and TEXT_PADDING variables hadn’t been updated. As I mentioned, these need to be changed so the side-to-side works.

io = 0
altri = 1

BUBBLE_COLORS = {io: "#90caf9", altri: "#a5d6a7"}
USER_TRANSLATE = {io: QPoint(20, 0), altri: QPoint(0, 0)}

BUBBLE_PADDING = QMargins(15, 5, 35, 5)
TEXT_PADDING = QMargins(25, 15, 45, 15)

In your email you said " the style of the other users is applied to my speech bubble". I thought you meant all bubbles had the same style. If you mean you want to swap the colours/positions for you vs. them, then just edit the variables to swap them around, e.g.

BUBBLE_COLORS = {altri: "#90caf9", io: "#a5d6a7"}
USER_TRANSLATE = {altri: QPoint(20, 0), io: QPoint(0, 0)}

Those two variables are all that changes the position/color of the bubbles.

ok, this has improved the style of the bubbles, but I mean another thing, I will send you an email with a screenshot attached

The problem is just with the logic of where to show the messages. A few things…

		while io >= 1:
			self.timer = QTimer()
			self.timer.timeout.connect(self.messaggio_a_)
			self.timer.start(0) 
		else:
			self.timer = QTimer()
			self.timer.timeout.connect(self.messaggio_da_)
			self.timer.start(0) 

What are you trying to do here? Since io is defined as a constant, one will always run – the second – because io is never > 1, it is 0.

Are you trying to run different methods depending on whether we are the “us” or “other” user? That’s backwards – a program or user isn’t “me” or “other” it’s both. I’m “me” to me, but “other” to someone else.

So it’s not the program/user who defines which bubble to show it’s the messages. If a message is from “me” it’s a “me” message, if it’s not, it’s a “them”. You just need to check the user who sent the message vs. the user of the program and save the appropriate state.

You ‘receive’ all messages (even those you send) so you can just check this in a single place, and test the username against the received message.

    def messaggio_a_(self):
        while nuovo_messaggio:
            if len(nuovo_messaggio) > 0:
                self.messaggio = nuovo_messaggio.pop(0)
                message_str = self.formato_del_messaggio(self.messaggio)

                if self.messaggio.get("name") == self.nome:
                    # Is our own message.
                    self.modello.add_message(io, message_str)

                else:
                    # Is other users message.
                    self.modello.add_message(altri, message_str)

You can just start a single timer and fire it against this

        self.timer = QTimer()
        self.timer.timeout.connect(self.messaggio_a_)
        self.timer.start(0)

If you want to swap the arrows around, you can do it with this bit here

        if user == io:
            p1 = bubblerect.topLeft()
        else:
            p1 = bubblerect.topRight()

That gives the following result

yes, it’s true, you’re right, massive thanks Martin, one last thing to complete the messages, if I wanted to add the time at the bottom of the message, what would you advise me to do?