Search code examples
qtpysideqpainterpyside6qgraphicstextitem

Elide the text of a QGraphicsTextItem when it exceeds a defined QRect


I have a QGraphicsTextItem that is a child of a QGraphicsPathItem which draws a box. I want the QGraphicsTextItem to only display text that fits within the box, if it overflows I want that text to be elided. enter image description here

I've been able to get this working, but with hardcoded values, which isn't ideal. Here is my basic code:

class Node(QtWidgets.QGraphicsPathItem):
    def __init__(self, scene, parent=None):
        super(Node, self).__init__(parent)

        scene.addItem(self)

        # Variables
        self.main_background_colour = QtGui.QColor("#575b5e")
        self.dialogue_background_colour = QtGui.QColor("#2B2B2B")
        self.dialogue_text_colour = QtGui.QColor("white")
        self.brush = QtGui.QBrush(self.main_background_colour)
        self.pen = QtGui.QPen(self.dialogue_text_colour, 2)

        self.dialogue_font = QtGui.QFont("Calibri", 12)
        self.dialogue_font.setBold(True)
        self.dialogue_font_metrics = QtGui.QFontMetrics(self.dialogue_font)

        self.dialogue_text = "To find out how fast you type, just start typing in the blank textbox on the right of the test prompt. You will see your progress, including errors on the left side as you type. You can fix errors as you go, or correct them at the end with the help of the spell checker. If you need to restart the test, delete the text in the text box. Interactive feedback shows you your current wpm and accuracy. Bring me all the biscuits, for I am hungry. They will be a fine meal for me and all the mice in town!"

        # Rects
        self.main_rect = QtCore.QRectF(0, -40, 600, 240)
        self.dialogue_rect = QtCore.QRectF(self.main_rect.x() + (self.main_rect.width() * 0.05), self.main_rect.top() + 10,
                          (self.main_rect.width() * 0.9), self.main_rect.height() - 20)

        self.dialogue_text_point = QtCore.QPointF(self.dialogue_rect.x() + (self.dialogue_rect.width() * 0.05), self.dialogue_rect.y() + 10)

        # Painter Paths
        self.main_path = QtGui.QPainterPath()
        self.main_path.addRoundedRect(self.main_rect, 4, 4)
        self.setPath(self.main_path)

        self.dialogue_path = QtGui.QPainterPath()
        self.dialogue_path.addRect(self.dialogue_rect)

        self.dialogue_text_item = QtWidgets.QGraphicsTextItem(self.dialogue_text, self)
        self.dialogue_text_item.setCacheMode(QtWidgets.QGraphicsPathItem.DeviceCoordinateCache)
        self.dialogue_text_item.setTextWidth(self.dialogue_rect.width() - 40)
        self.dialogue_text_item.setFont(self.dialogue_font)
        self.dialogue_text_item.setDefaultTextColor(self.dialogue_text_colour)
        self.dialogue_text_item.setPos(self.dialogue_text_point)

        # HARDCODED ELIDE
        elided = self.dialogue_font_metrics.elidedText(self.dialogue_text, QtCore.Qt.ElideRight, 3300)
        self.dialogue_text_item.setPlainText(self.dialogue_text) # elided

        # Flags
        self.setFlag(self.ItemIsMovable, True)
        self.setFlag(self.ItemSendsGeometryChanges, True)
        self.setFlag(self.ItemIsSelectable, True)
        self.setFlag(self.ItemIsFocusable, True)
        self.setCacheMode(QtWidgets.QGraphicsPathItem.DeviceCoordinateCache)

    def boundingRect(self):
        return self.main_rect

    def paint(self, painter, option, widget=None):
        # Background
        self.brush.setColor(self.main_background_colour)
        painter.setBrush(self.brush)

        painter.drawPath(self.path())

        # Dialogue
        self.brush.setColor(self.dialogue_background_colour)
        painter.setBrush(self.brush)
        self.pen.setColor(self.dialogue_background_colour.darker())
        painter.setPen(self.pen)

        painter.drawPath(self.dialogue_path)

This is what I've tried to use, but my maths is off. I think I'm approaching this in the wrong way:

    # Dialogue
    text_length = self.dialogue_font_metrics.horizontalAdvance(self.dialogue_text)
    text_metric_rect = self.dialogue_font_metrics.boundingRect(QtCore.QRect(0, 0, self.dialogue_text_item.textWidth(), self.dialogue_font_metrics.capHeight()), QtCore.Qt.TextWordWrap, self.dialogue_text)
    
    elided_length = (text_length / text_metric_rect.height()) * (self.dialogue_rect.height() - 20)
    elided = self.dialogue_font_metrics.elidedText(self.dialogue_text, QtCore.Qt.ElideRight, 3300)

    self.dialogue_text_item.setPlainText(elided)

Any suggestions would be appreciated!


Solution

  • The QFontMetrics elide function only works for a single line of text, and cannot be used for layed out text, which is what happens when word wrapping or new lines are involved.
    Even trying to set the width for the elide function based on an arbitrary size, it wouldn't be valid: whenever a line is wrapped, the width used as reference for that line is "reset".

    Imagine that you want the text to be 50 pixels wide, so you suppose that some text would be split in two lines, with a total of 100 pixels. Then you have three words in that text, each 40 pixels wide, for which the result of elidedText() with 100 pixels will be that you'll have all three words, with the last one elided.
    Then you set that text with word wrapping enabled and a maximum width of 50 pixels: the result will be that you'll only see the first two words, because each line can only fit one word.

    The only viable solution is to use QTextLayout, and iterate through all the text lines it creates, then, if the height of the next line exceeds the maximum height, you call elidedText() for that line only.

    Be aware, though, that this assumes that the format (font, font size and weight) will always be the same along the whole text. More advanced layouts are possible, but it requires more advanced use of QTextDocument features, QTextLayout and QTextFormat.

            textLayout = QtGui.QTextLayout(self.dialogue_text, dialogue_font)
            height = 0
            maxWidth = text_rect.width()
            maxHeight = text_rect.height()
            textLayout.beginLayout()
            text = ''
            while True:
                line = textLayout.createLine()
                if not line.isValid():
                    break
                line.setLineWidth(maxWidth)
                text += self.dialogue_text[
                    line.textStart():line.textStart() + line.textLength()]
                line.setPosition(QtCore.QPointF(0, height))
                height += line.height()
                if height + line.height() > maxHeight:
                    line = textLayout.createLine()
                    line.setLineWidth(maxWidth)
                    line.setPosition(QtCore.QPointF(0, height))
                    if line.isValid():
                        last = self.dialogue_text[line.textStart():]
                        fm = QtGui.QFontMetrics(dialogue_font)
                        text += fm.elidedText(last, QtCore.Qt.ElideRight, maxWidth)
                    break
    

    Note that your item implementation is a bit questionable: first of all, you're practically not using any of the features of QGraphicsPathItem, since you're overriding both paint() and boundingRect().

    If you want to do something like that, just use a basic QGraphicsItem, otherwise always try to use the existing classes and functions Qt provides, which is particularly important for the Graphics View framework, which relies on the C++ optimizations: overriding paint() forces the drawing to pass through python, which is a huge bottleneck, especially when many items are involved.

    Instead of painting everything, create child items with properly set properties.

    Finally, an item should not add itself to a scene.

    Here's a better, simpler (and more readable) implementation that considers all the above:

    class Node(QtWidgets.QGraphicsPathItem):
        def __init__(self, parent=None):
            super(Node, self).__init__(parent)
    
            self.setBrush(QtGui.QColor("#575b5e"))
            
            main_rect = QtCore.QRectF(0, -40, 600, 140)
            path = QtGui.QPainterPath()
            path.addRoundedRect(main_rect, 4, 4)
            self.setPath(path)
    
            hMargin = main_rect.width() * .05
            vMargin = 10
            dialogue_rect = main_rect.adjusted(hMargin, vMargin, -hMargin, -vMargin)
    
            dialogue_item = QtWidgets.QGraphicsRectItem(dialogue_rect, self)
            dialogue_color = QtGui.QColor("#2B2B2B")
            dialogue_item.setPen(QtGui.QPen(dialogue_color.darker(), 2))
            dialogue_item.setBrush(dialogue_color)
    
            text_rect = dialogue_rect.adjusted(hMargin, vMargin, -hMargin, -vMargin)
            dialogue_font = QtGui.QFont("Calibri", 12)
            dialogue_font.setBold(True)
    
            self.dialogue_text = "To find out how fast you type, just start typing "\
                "in the blank textbox on the right of the test prompt. You will see "\
                "your progress, including errors on the left side as you type. You "\
                "can fix errors as you go, or correct them at the end with the help "\
                "of the spell checker. If you need to restart the test, delete the "\
                "text in the text box. Interactive feedback shows you your current "\
                "wpm and accuracy. Bring me all the biscuits, for I am hungry. They "\
                "will be a fine meal for me and all the mice in town!"
    
            textLayout = QtGui.QTextLayout(self.dialogue_text, dialogue_font)
            height = 0
            maxWidth = text_rect.width()
            maxHeight = text_rect.height()
            textLayout.beginLayout()
            text = ''
            while True:
                line = textLayout.createLine()
                if not line.isValid():
                    break
                line.setLineWidth(maxWidth)
                text += self.dialogue_text[
                    line.textStart():line.textStart() + line.textLength()]
                line.setPosition(QtCore.QPointF(0, height))
                height += line.height()
                if height + line.height() > maxHeight:
                    line = textLayout.createLine()
                    line.setLineWidth(maxWidth)
                    line.setPosition(QtCore.QPointF(0, height))
                    if line.isValid():
                        last = self.dialogue_text[line.textStart():]
                        fm = QtGui.QFontMetrics(dialogue_font)
                        text += fm.elidedText(last, QtCore.Qt.ElideRight, maxWidth)
                    break
    
            doc = QtGui.QTextDocument(text)
            doc.setDocumentMargin(0)
            doc.setDefaultFont(dialogue_font)
            doc.setTextWidth(text_rect.width())
    
            self.dialogue_text_item = QtWidgets.QGraphicsTextItem(self)
            self.dialogue_text_item.setDocument(doc)
            self.dialogue_text_item.setCacheMode(self.DeviceCoordinateCache)
            self.dialogue_text_item.setDefaultTextColor(QtCore.Qt.white)
            self.dialogue_text_item.setPos(text_rect.topLeft())
    
            # Flags
            self.setFlag(self.ItemIsMovable, True)
            self.setFlag(self.ItemSendsGeometryChanges, True)
            self.setFlag(self.ItemIsSelectable, True)
            self.setFlag(self.ItemIsFocusable, True)
            self.setCacheMode(self.DeviceCoordinateCache)