I need to make the message_container
adjust to the size of the label, but up to a maximum of 60% of the scroll_area width. And for some reason, even the short text is moved to a new line.
from PyQt6.QtWidgets import QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QScrollArea
from PyQt6.QtCore import Qt
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("JChatting")
self.setGeometry(100, 100, 800, 600)
self.username = "user1"
# Main widget and layout
main_widget = QWidget()
main_layout = QVBoxLayout()
main_widget.setLayout(main_layout)
self.setCentralWidget(main_widget)
# Chat Display
self.scroll_area = QScrollArea()
self.scroll_area.setWidgetResizable(True)
self.chat_container = QWidget()
self.chat_layout = QVBoxLayout()
self.chat_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
self.chat_container.setLayout(self.chat_layout)
self.scroll_area.setWidget(self.chat_container)
main_layout.addWidget(self.scroll_area)
self.add_messages()
def display_message(self, sender, message_text):
"""Display a message in the chat."""
message_widget = QWidget()
message_layout = QVBoxLayout()
message_widget.setLayout(message_layout)
# Create message container
message_container = QWidget()
message_container_layout = QHBoxLayout()
message_container_layout.setContentsMargins(0, 0, 0, 0)
message_container_layout.setSpacing(5)
message_container.setLayout(message_container_layout)
max_container_width = int(self.scroll_area.viewport().width() * 0.6)
# Styling the message
label = QLabel(f"{sender}: {message_text}")
label.setWordWrap(True)
label.setStyleSheet(f"font-size: 14px;")
styleSheet = f"""
border-radius: 10px;
padding: 10px;
max-width: {max_container_width};
"""
if sender == self.username:
message_container.setStyleSheet(f"{styleSheet} background-color: #282828;")
message_layout.setAlignment(Qt.AlignmentFlag.AlignLeft)
else:
message_container.setStyleSheet(f"{styleSheet} background-color: #505050;")
message_layout.setAlignment(Qt.AlignmentFlag.AlignRight)
# Add label to container and container to message layout
message_container_layout.addWidget(label)
message_layout.addWidget(message_container)
self.chat_layout.addWidget(message_widget)
# Auto-scroll to the latest message
self.scroll_area.verticalScrollBar().setValue(
self.scroll_area.verticalScrollBar().maximum()
)
def add_messages(self):
messages = [
("user1", "bim bim"),
("Friend4", "bam bam"),
("Friend4", "bom bom"),
("user1", "bim bim"),
("user1", "BAM BAM BAM"),
("user1", "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s..."),
("Friend4", "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s...")
]
for sender, message_text in messages:
self.display_message(sender, message_text)
if __name__ == "__main__":
app = QApplication([])
window = MainWindow()
window.show()
app.exec()
I tried to do this by resizing the text and enabling label.setWordWrap(True)
only for those that exceed the maximum width of the container, but it didn't work correctly. Some messages still did not fit into the container and were moved to another line, and the container itself did not expand.
I am only partially able to reproduce this on Qt5 and just under certain circumstances (based on the specific width of the scroll area contents and possibly related to the used font).
Qt6 introduced some changes and I can reproduce the behavior more consistently.
The main source of the problem, though, is known and pretty old, and it is reported in the issues section of the Layout documentation:
The use of rich text in a label widget can introduce some problems to the layout of its parent widget. Problems occur due to the way rich text is handled by Qt's layout managers when the label is word wrapped.
Note: Qt layouts haven't changed much in the last 10-15 years, but the linked documentation wasn't kept updated as it should, and it mentions aspects that are not valid anymore (notably, the QLayout::FreeResize
reference, which doesn't exist since Qt4).
Yet, what mainly addressed then is still valid nowadays: QLabel word wrapping should always be set with awareness about those issues, and those issues will likely never be fixed, due to the way Qt layouts and the Qt text engine works.
The above is especially relevant in your case, for the following reasons:
min-width
property using an arbitrary (and unreliable) width;QWidget provides the heightForWidth()
function that could be overridden (as long as the related hasHeightForWidth()
returns True
) by standard as custom widgets. This is the case for QLabel, in case it has word wrap enabled.
The default behavior (which normally works for simple cases), though, can unexpectedly return unreliable heights in some circumstances.
The first thing to do, then, is to create a subclass of QLabel and implement it accordingly. Note that heightForWidth()
is not always considered as an absolute reference, but only as a hint, also because the layout may consider that value and further update its internal geometries.
Therefore, in order to ensure that the label has the correct height, we need to "force" it in some way.
One possibility is to update the label minimum height whenever it is resized, which can be achieved by overriding resizeEvent()
. Note that, normally, this is discouraged, because it can cause partial or even infinite recursion: when resizeEvent()
has been received, the widget has already been resized, therefore trying to force its size constraints could cause a further resizeEvent()
calls.
Doing this in a widget that we know is always within a scroll area is normally safe, though.
Also, since the sizeHint()
of a word wrapped QLabel is based on an acceptably readable size (to avoid extremely long lines of text), we need to enforce the hint based on the text without considering word wrapping.
class CustomLabel(QLabel):
def sizeHint(self):
hint = super().sizeHint()
hint.setWidth(self.fontMetrics().boundingRect(
QRect(), 0, self.text()).width())
return hint
def heightForWidth(self, w):
margins = self.contentsMargins()
w -= margins.left() + margins.right() + self.margin() * 2
if w < 0:
w = 0
return self.fontMetrics().boundingRect(
0, 0, w, 2000,
Qt.TextFlag.TextWordWrap, self.text()
).height()
def resizeEvent(self, event):
super().resizeEvent(event)
heightHint = self.heightForWidth(self.width())
if self.height() != heightHint:
self.setMinimumHeight(heightHint)
self.updateGeometry()
Important: the above is based on the fact that the text is always plain text, and it does not contain any rich text content (variable font sizes and formats, images, etc). Specifically, if you're using HTML or markdown, the text()
will consider tags and tokens in their raw form.
If that is required, both sizeHint
and heightForWidth()
should then try to access the private QWidgetTextControl and its QTextDocument in order to temporarily set its width and get the correct hints, or fall back to the above in case the control is not created by the label.
Unfortunately, the QLabel implementation won't be enough, as there are other issues that have to be addressed in your case.
The first problem is caused by your attempt to set a minimum width in the QSS. Not only this is often inappropriate (unless you really know its behavior), but it also results in setting an arbitrary minimum width that is based on the viewport size when the message is added. There are two reasons for which this is inappropriate:
add_messages()
is called before the window is shown, the viewport will still have the default size all widgets have before being actually shown (640x480 if created without a parent, 100x30 otherwise);Since you want to have labels have a maximum width always based on the current viewport width, then you need to ensure that it's updated every time the viewport is resized. Once that happens, every widget in the scroll area need to have a new maximum width set, and this must be done using the appropriate setMaximumWidth()
call, not using style sheets.
All this can only be achieved by installing an event filter on the viewport, but also delaying the maximum width setting, because scroll areas update their contents after a slight delay to prevent flickering and recursion when doing opaque resizing.
Before showing the updated code, other aspects must be addressed as well.
I don't know why you used a 2-levels nested structure (message_widget
> message_container
> label
), you may need to add further widgets you're not showing, but if you don't, you should avoid doing that. At the very least, you could get rid of the message_container
and just use a nested layout instead, for instance by doing message_layout.addLayout(message_container_layout)
(assuming you do need a further layout there).
Then there is the issue of using generic properties on a container (message_container.setStyleSheet(...)
), which is always discouraged, because generic properties are always inherited by children.
It may not have been related to your problem (according to the docs, including the Qt6 ones, min-width
should not affect the basic QWidget), but the fact remains: you should always use selector types in container widgets or complex ones (including scroll areas).
I addressed the above by creating the message container as a QFrame (instead of QWidget), setting an appropriate object name, and updating the QSS contents accordingly.
Setting the layout alignment may also be an issue, at least related to what explained at the beginning of this post, therefore we should try to minimize the problem at least by setting the layout alignment on the added widget.
Finally, while unrelated, trying to scroll to the bottom right after adding a new widget will probably never work. As mentioned above, layout adjustments to the contents of a scroll area usually happen after a slight delay.
class MainWindow(QMainWindow):
def __init__(self):
...
self.scroll_area.viewport().installEventFilter(self)
self.fixTimer = QTimer(self, singleShot=True,
timeout=self.fixMessageSizes)
self.scrollTimer = QTimer(self, singleShot=True,
interval=1, timeout=self.scrollToBottom)
self.add_messages()
def eventFilter(self, obj, event):
if event.type() == event.Type.Resize:
self.fixTimer.start()
return super().eventFilter(obj, event)
def fixMessageSizes(self):
width = int(self.scroll_area.viewport().width() * .6)
changed = False
vb = self.scroll_area.verticalScrollBar()
scrollToBottom = vb.value() == vb.maximum()
for i in range(self.chat_layout.count()):
widget = self.chat_layout.itemAt(i).widget()
if widget is not None:
minWidth = widget.minimumWidth()
maxWidth = widget.maximumWidth()
if (
minWidth != maxWidth
or minWidth != width
):
changed = True
widget.setMaximumWidth(width)
if scrollToBottom and changed and not self.scrollTimer.isActive():
self.scrollTimer.start()
def scrollToBottom(self):
self.chat_layout.activate()
QApplication.processEvents()
self.scroll_area.verticalScrollBar().setValue(
self.scroll_area.verticalScrollBar().maximum())
def display_message(self, sender, message_text):
"""Display a message in the chat."""
message_widget = QWidget()
message_layout = QVBoxLayout()
message_widget.setLayout(message_layout)
# Create message container
message_container = QFrame(objectName='container')
message_container_layout = QHBoxLayout()
message_container_layout.setContentsMargins(0, 0, 0, 0)
message_container_layout.setSpacing(5)
message_container.setLayout(message_container_layout)
# Styling the message
label = CustomLabel(f"{sender}: {message_text}")
label.setWordWrap(True)
label.setObjectName('message')
if sender == self.username:
background = '#282828'
alignment = Qt.AlignmentFlag.AlignLeft
else:
background = '#505050'
alignment = Qt.AlignmentFlag.AlignRight
message_container.setStyleSheet(f'''
#container {{
border-radius: 10px;
padding: 10px;
background: {background};
}}
QLabel#message {{
color: palette(light);
font-size: 14px;
}}
''')
# Add label to container and container to message layout
message_container_layout.addWidget(label)
message_layout.addWidget(message_container)
self.chat_layout.addWidget(message_widget, alignment=alignment)
self.scrollTimer.start()
self.fixTimer.start()
...