As part of my work on the
Krita Custom Status Bar Plugin
I wanted to do something conceptually very simple:
Display a rich-text string with ellipsis if it exceedes the available space.
Obviously, if I’m writing this it must be hard. Like 150 lines of finiky code hard.
So here’s the code then:
# These are “Qt.py” imports, should work identically for PySide6 but may
# require small adaptions for PyQt5/6 or PySide2
from .vendored.Qt import QtCore as qtcore
from .vendored.Qt import QtGui as qtgui
from .vendored.Qt import QtWidgets as qtwidgets
class ElidedLabel(qtwidgets.QFrame):
"""
Python adaption of
https://doc.qt.io/archives/qt-5.15/qtwidgets-widgets-elidedlabel-example.html
https://stackoverflow.com/a/66412942/277882
Provides an elided variant of QLabel that supports rich text. Relies on
QTextDocument to provide efficient relayouting while repeatedly removing
extraneous lines.
"""
_content: str
_elided: bool
elidedChanged = qtcore.Signal(bool)
def __init__(self, parent: qtwidgets.QWidget | None = None) -> None:
super().__init__(parent)
self._elided = False
self._content = ""
self.setSizePolicy(
qtwidgets.QSizePolicy.Policy.Expanding,
qtwidgets.QSizePolicy.Policy.Preferred,
)
def setText(self, text: str) -> None:
if text != self._content:
self._content = text
self.update() # Trigger re-layout
def text(self) -> str:
return self._content
def isElided(self) -> bool:
return self._elided
def paintEvent(self, event: qtgui.QPaintEvent) -> None:
super().paintEvent(event)
elided = False
# Painting target
painter = qtgui.QPainter(self)
# Construct rich-text document layouter with text
layouter = qtgui.QTextDocument(self)
layouter.setHtml(self._content)
layouter.setDefaultFont(painter.font())
layouter.setDocumentMargin(0)
# Set correct painter device *after* content to ensure correct DPI scaling
layouter.documentLayout().setPaintDevice(painter.device())
# Layout document to expected width, insert line breaks
#
# Crucially `setTextWidth` will break text based on available space,
# so we only have to cut off extra lines and then truncate the last
# line. Must be called last after setting up document.
layouter.setTextWidth(self.width())
cursor = qtgui.QTextCursor(layouter)
cursor.movePosition(qtgui.QTextCursor.MoveOperation.End)
def layouter_lines() -> int:
lines = 0
block = layouter.begin()
while block.isValid():
layout = block.layout()
lines += layout.lineCount() if layout is not None else 0
block = block.next()
return lines
# Ensure height fits into available space
if layouter_lines() > 1 and layouter.size().height() > self.height():
elided = True
#fragment: qtgui.QTextDocumentFragment | None = None
# Delete lines until text fits into layout
while layouter_lines() > 1 and layouter.size().height() > self.height():
# Select from current end to end of previous line
cursor.movePosition(
qtgui.QTextCursor.MoveOperation.Up,
qtgui.QTextCursor.MoveMode.KeepAnchor,
)
cursor.movePosition(
qtgui.QTextCursor.MoveOperation.EndOfLine,
qtgui.QTextCursor.MoveMode.KeepAnchor,
)
# Extract and delete selection
#
# Cursor is again at end of document after this.
fragment = cursor.selection()
cursor.removeSelectedText()
# Re-layout for next loop iteration
layouter.markContentsDirty(0, layouter.characterCount())
# Reinsert last fragment, remaining text will be deleted
# character-by-character as we would otherwise loose the last word
# of the last visible line rather than truncating it
cursor.insertFragment(fragment)
# Add elision mark if some text was chopped off
if elided:
line_target = layouter_lines() - 1
# Remove character of last line until it fits into the `line_target`
cursor.movePosition(qtgui.QTextCursor.MoveOperation.End)
while layouter_lines() > line_target:
cursor.deletePreviousChar()
layouter.markContentsDirty(0, layouter.characterCount())
# Optimistically add elision mark at end
char_format = qtgui.QTextCharFormat() # Default text formatting
char_format.setFont(painter.font())
last_good_position = cursor.position()
cursor.insertText("…", char_format)
layouter.markContentsDirty(0, layouter.characterCount())
# Drop characters preceeding elision mark until text fits
while layouter_lines() > line_target:
# Move to one left of where we inserted the elision mark and
# delete span
cursor.movePosition(qtgui.QTextCursor.MoveOperation.End)
cursor.setPosition(
last_good_position,
qtgui.QTextCursor.MoveMode.KeepAnchor,
)
cursor.movePosition(
qtgui.QTextCursor.MoveOperation.Left,
qtgui.QTextCursor.MoveMode.KeepAnchor,
)
cursor.removeSelectedText()
# Re-add elision mark at new position and trigger relayout
last_good_position = cursor.position()
cursor.insertText("…", char_format)
layouter.markContentsDirty(0, layouter.characterCount())
# Finally draw remaining content
painter.translate(0, (self.height() - layouter.size().height()) / 2)
layouter.drawContents(painter)
if self._elided != elided:
self._elided = elided
self.elidedChanged.emit(elided)
With usage being simply:
class Window(qtwidgets.QWindow):
def __init__(self):
super().__init__()
label = ElidedLabel(self)
label.setText(
"Soooooooooooooooooooooooooooooooooooooooome Teeeeeeeeeeeeeeeeeeext <span style=\"color: palette(midlight)\">|</span>"
"Moooooooooooooooooooooooooooooooooooooooore Teeeeeeeeeeeeeeeeeeext <span style=\"color: palette(midlight)\">|</span>"
"Fiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiinnnnnnnnnnnnnnnnnnnnnaaaaaaaaaaaaaaaallllllllllllllllllllllyyyyyyyyyyyy Done"
)
What it does is using QTextDocument,
Qt’s multi-line rich-text document layouting and rendering engine as first a
parser of the supplied rich-text (in this case HTML, but could also be Markdown
since Qt 5.14), then instruct QTextDocument to apply word wrapping to the
available width and then removing lines from the resulting document until it
fits into the available space. This way, at least we only need to adjust for
the available vertical space since QTextDocument already takes care to keep
the supplied in the available horizontal space area.
The second part of the logic then truncates first overflowing line until we find the exact last character that still fits into the available space, since the goal here was to ellipsize the first extra word rather than just removing it.
Finally it attempts to add the Unicode ellipsis (…) character and handles
removing any further required characters (generally exactly 1 or 2) to also
make the ellipsis fit. 😮💨
The remaining code is just to give the widget a native Qt-ish feel and is mostly based on the C++ Elided Text Label published with the Qt5 documentation (which only handles plain text, not rich text). A StackOverflow answer by Raven and lots of puzzling provided the key insights to adopt this to rich text.
Only making the Krita plugin translatable came anywhere near this level of “Uff”. 😅
All code on this page is provided in the public domain or, should this not be possible, under the terms of the CC0 public domain waiver or its fallback license.