Another small problem gone big

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.

Was this article useful? Consider leaving a star!