Chat Speech Bubbles in PyQt6 with QListView and Custom Delegates

Build a messaging-style interface with colored speech bubbles, timestamps, and left/right alignment
Heads up! You've already completed this tutorial.

How can I add speech bubbles like Telegram or WhatsApp around messages in a Python Qt application? I'm currently using a QTextEdit, but I'd like each message to appear in its own styled bubble.

A QTextEdit treats everything as one continuous block of text, which makes it awkward to style individual messages with their own bubbles, colors, and positions. A much better fit for a chat interface is a QListView, where each message is a separate item in a list. You can then use a custom delegate to paint each message with its own speech bubble.

In this tutorial, you'll build a chat-style message view from scratch using PyQt6. By the end, you'll have colored speech bubbles that wrap text properly, align to the left or right depending on the sender, and display timestamps — just like a real messaging app.

Setting up the model

Qt's Model/View architecture separates data from presentation. We'll store our messages in a simple list model, and let a delegate handle all the drawing.

Each message is a tuple of (user, text), where user is either USER_ME (0) or USER_THEM (1). This tells the delegate which side of the window to draw the bubble on and what color to use.

Here's the model:

python
from PyQt6.QtCore import QAbstractListModel, Qt


USER_ME = 0
USER_THEM = 1


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

    def data(self, index, role):
        if role == Qt.ItemDataRole.DisplayRole:
            return self.messages[index.row()]

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

    def add_message(self, who, text):
        """
        Add a message to the list.
        """
        if text:
            self.messages.append((who, text))
            self.layoutChanged.emit()

The data method returns the full (user, text) tuple for each row. The delegate will unpack this when it paints.

If you're not familiar with Qt's Model/View system, take a look at the Model View Architecture tutorial for background.

Drawing bubbles with a custom delegate

A QStyledItemDelegate lets you take full control over how each item in a QListView is drawn. We override two methods:

  • paint() — draws the bubble and text.
  • sizeHint() — tells the view how tall each item should be.

A first version

Let's start with a simple delegate that draws a rounded rectangle (the bubble) and the message text inside it:

python
from PyQt6.QtCore import QMargins, QPoint, QRectF
from PyQt6.QtGui import QColor, QTextDocument, QTextOption
from PyQt6.QtWidgets import QStyledItemDelegate


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):

    def paint(self, painter, option, index):
        painter.save()

        user, text = index.model().data(index, Qt.ItemDataRole.DisplayRole)

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

        # Draw the bubble background.
        painter.setPen(Qt.PenStyle.NoPen)
        color = QColor(BUBBLE_COLORS[user])
        painter.setBrush(color)
        painter.drawRoundedRect(bubblerect, 10, 10)

        # Draw a small triangle pointer on the bubble.
        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 message text.
        toption = QTextOption()
        toption.setWrapMode(QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere)

        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.ItemDataRole.DisplayRole)
        textrect = option.rect.marginsRemoved(TEXT_PADDING)

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

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

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

There are a few things worth noting here:

QTextDocument for text layout. We use QTextDocument both in paint() and sizeHint(). This ensures the text wrapping calculation matches the actual rendering exactly. Earlier approaches using QFontMetrics and painter.drawText() separately led to mismatches where text would get clipped.

WrapAtWordBoundaryOrAnywhere. This wrap mode breaks text between words when possible, but will also break mid-word if a single word is longer than the available width. This prevents long strings from overflowing the bubble.

painter.save() and painter.restore(). Because we call painter.translate() to position the text, we need to save and restore the painter state. Otherwise, each successive item would be drawn at an offset from the previous one.

Padding with QMargins. BUBBLE_PADDING controls the space between the edge of the list item and the bubble. TEXT_PADDING controls the space between the edge of the list item and the text. The difference between the two creates an inner margin inside the bubble.

Aligning bubbles left and right

In a real chat app, your own messages appear on the right and other people's messages appear on the left. We can achieve this by translating the painter's origin before drawing, and adjusting the padding to leave room on the appropriate side.

Add a translation mapping at the top of the file:

python
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)

The extra padding on the right side (35 and 45 instead of 15 and 25) creates space for the offset. USER_ME messages are shifted 20 pixels to the right; USER_THEM messages stay at the left edge.

In the paint() method, apply the translation right after saving the painter:

python
    def paint(self, painter, option, index):
        painter.save()

        user, text = index.model().data(index, Qt.ItemDataRole.DisplayRole)

        # Shift the bubble left or right depending on the sender.
        trans = USER_TRANSLATE[user]
        painter.translate(trans)

        # ... rest of the painting code stays the same.

Now "me" messages appear shifted to the right, and "them" messages sit on the left — just like a real messaging app.

Adding timestamps

To display a timestamp under each message, you need to:

  1. Store the timestamp alongside the message in the model.
  2. Draw it in the delegate's paint() method.
  3. Add extra height in sizeHint() to make room for it.

Update the model to accept a third value — the timestamp:

python
from time import time


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

    def data(self, index, role):
        if role == Qt.ItemDataRole.DisplayRole:
            return self.messages[index.row()]

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

    def add_message(self, who, text):
        if text:
            self.messages.append((who, text, time()))
            self.layoutChanged.emit()

time() returns the current Unix timestamp as a float. We store it alongside the user and text.

Update sizeHint() to add space for the timestamp line. We just need an extra 20 pixels of height:

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

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

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

        textrect.setHeight(int(doc.size().height()))
        textrect = textrect.marginsAdded(TEXT_PADDING)
        return textrect.size() + QSize(0, 20)

And update paint() to draw the timestamp text below the message. We position it at the bottom-left of the text rectangle, with a small vertical offset:

python
from datetime import datetime

# Inside paint(), after drawing the bubble but before drawing the message text:

        # Draw the timestamp in a smaller font.
        font = painter.font()
        font.setPointSize(7)
        painter.setFont(font)
        painter.setPen(Qt.GlobalColor.black)
        time_str = datetime.fromtimestamp(timestamp).strftime("%H:%M")
        painter.drawText(textrect.bottomLeft() + QPoint(0, 5), time_str)

The timestamp appears just below the message, inside the bubble.

Adding spacing between bubbles

By default, items in a QListView sit right next to each other. To add vertical spacing between bubbles, use the setSpacing() method on the list view:

python
self.messages = QListView()
self.messages.setSpacing(5)

This adds 5 pixels of space around each item. Adjust the value to suit your design.

Complete working example

Here's the full application with colored bubbles, left/right alignment, timestamps, and spacing:

python
import sys
from datetime import datetime
from time import time

from PyQt6.QtCore import (
    QAbstractListModel,
    QMargins,
    QPoint,
    QSize,
    Qt,
)
from PyQt6.QtGui import QColor, QTextDocument, QTextOption
from PyQt6.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 as a speech bubble.
    """

    def paint(self, painter, option, index):
        painter.save()

        user, text, timestamp = index.model().data(
            index, Qt.ItemDataRole.DisplayRole
        )

        # Shift the bubble depending on the sender.
        trans = USER_TRANSLATE[user]
        painter.translate(trans)

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

        # Draw the bubble background.
        painter.setPen(Qt.PenStyle.NoPen)
        color = QColor(BUBBLE_COLORS[user])
        painter.setBrush(color)
        painter.drawRoundedRect(bubblerect, 10, 10)

        # Draw the triangle pointer.
        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 timestamp below the text area.
        font = painter.font()
        font.setPointSize(7)
        painter.setFont(font)
        painter.setPen(Qt.GlobalColor.black)
        time_str = datetime.fromtimestamp(timestamp).strftime("%H:%M")
        painter.drawText(textrect.bottomLeft() + QPoint(0, 5), time_str)

        # Draw the message text using QTextDocument for proper wrapping.
        toption = QTextOption()
        toption.setWrapMode(
            QTextOption.WrapMode.WrapAtWordBoundaryOrAnywhere
        )

        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.ItemDataRole.DisplayRole
        )
        textrect = option.rect.marginsRemoved(TEXT_PADDING)

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

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

        textrect.setHeight(int(doc.size().height()))
        textrect = textrect.marginsAdded(TEXT_PADDING)
        # Add 20px for the timestamp line.
        return textrect.size() + QSize(0, 20)


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

    def data(self, index, role):
        if role == Qt.ItemDataRole.DisplayRole:
            return self.messages[index.row()]

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

    def add_message(self, who, text):
        """
        Add a message to the chat.
        """
        if text:
            self.messages.append((who, text, time()))
            self.layoutChanged.emit()


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Chat Bubbles")

        layout = QVBoxLayout()

        self.message_input = QLineEdit()
        self.message_input.setPlaceholderText("Type a message...")

        # Buttons to simulate sending and receiving messages.
        self.btn_send = QPushButton("Send (Me) →")
        self.btn_receive = QPushButton("← Receive (Them)")

        self.message_list = QListView()
        self.message_list.setSpacing(5)
        self.message_list.setResizeMode(QListView.ResizeMode.Adjust)
        self.message_list.setItemDelegate(MessageDelegate())

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

        self.btn_send.pressed.connect(self.send_message)
        self.btn_receive.pressed.connect(self.receive_message)

        layout.addWidget(self.message_list)
        layout.addWidget(self.message_input)
        layout.addWidget(self.btn_send)
        layout.addWidget(self.btn_receive)

        container = QWidget()
        container.setLayout(layout)
        self.setCentralWidget(container)

    def resizeEvent(self, e):
        # Recalculate bubble sizes when the window is resized.
        self.model.layoutChanged.emit()

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

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


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

Run this and type a message into the text field. Press Send (Me) → to add a blue bubble on the right, or ← Receive (Them) to add a green bubble on the left. Each bubble displays the message text with proper word wrapping and a timestamp underneath.

How it all fits together

The architecture follows Qt's Model/View pattern:

  • MessageModel holds a list of (user, text, timestamp) tuples. It provides data to the view when requested.
  • QListView displays the items and handles scrolling and layout.
  • MessageDelegate controls the visual appearance of each item. It uses QTextDocument for accurate text measurement and rendering, and draws the bubble shape with QPainter.

When you call add_message(), the model appends the new message and emits layoutChanged, which tells the view to refresh. The view asks the delegate for each item's size (via sizeHint) and then asks it to paint each visible item.

This approach scales well. You can extend it to support features like different fonts, inline images, read receipts, or message status indicators — all by adding data to the model and updating the delegate's paint logic.

Well done, you've finished this tutorial! Mark As Complete
[[ user.completed.length ]] completed [[ user.streak+1 ]] day streak

Create GUI Applications with Python & Qt6 by Martin Fitzpatrick

(PySide6 Edition) The hands-on guide to making apps with Python — Over 15,000 copies sold!

More info Get the book

Martin Fitzpatrick

Chat Speech Bubbles in PyQt6 with QListView and Custom Delegates was written by Martin Fitzpatrick.

Martin Fitzpatrick has been developing Python/Qt apps for 8 years. Building desktop applications to make data-analysis tools more user-friendly, Python was the obvious choice. Starting with Tk, later moving to wxWidgets and finally adopting PyQt. Martin founded PythonGUIs to provide easy to follow GUI programming tutorials to the Python community. He has written a number of popular Python books on the subject.