0

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!

Adam Sirrelle
  • 357
  • 8
  • 18
  • 1
    Please try to provide an actual *reproducible* example, your code has many attributes that are missing or have the wrong type. Also, you're practically *not* using the QGraphicsPathItem, since you're completely overriding its `paint()` method instead of using the base implementation. – musicamante Apr 11 '22 at 09:15
  • The main code has been amended, I missed a few variables >< Thank you for pointing this out! – Adam Sirrelle Apr 11 '22 at 09:23

1 Answers1

2

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)
musicamante
  • 41,230
  • 6
  • 33
  • 58
  • Thank you so much for your help and advice! I found one issue where you need another - if not line.isValid(): break - after performing the height check in the while loop, when the text is short enough not to elide. This has been a huge help, thank you so much ^^ – Adam Sirrelle Apr 12 '22 at 01:25
  • @AdamSirrelle You're very welcome. I'm not sure I've understood what you're pointing out (but maybe I'm just too tired right now): if the text is actually short enough to be fully displayed, the text elision should not be called; but, it's important to understand that font related matters are extremely tricky, and it completely depends on the font contents. Can you provide a couple of screenshots showing the issue? – musicamante Apr 12 '22 at 01:44
  • Ah, I see. You do have a - if line.isValid() - but its a couple of lines after you try to - line.setLineWidth(maxWidth) - which causes the code to crash when passing shorter text. Moving that up and putting "line.setLineWidth(maxWidth)" and "line.setPosition(QtCore.QPointF(0, height))" after that condition should fix this. – Adam Sirrelle Apr 12 '22 at 01:53
  • I think I must have been passing through the "perfect" length text string, which maybe created a new line with a length of 0, or something. This for reference: "To find out how fast you type, just start typing " \ "in the blank textbox on the right of the test prompt. You will see " – Adam Sirrelle Apr 12 '22 at 01:56
  • 1
    @AdamSirrelle Yes, sorry, you're right, the `line.isValid()` should be the first line before attempting anything else. About the "perfect" line, I've to admit that I underestimated it: I considered that aspect, but I consciously (and wrongly) avoided it. I cannot fix it right now, but I can suggest you to take a look at the [sources](//code.woboq.org/qt5/qtbase/src/widgets/styles/qcommonstyle.cpp.html) (see `calculateElidedText`) to see how the default implementation does it. Specifically, consider the end of the last (possible) line, and fix the possible result according to the font metrics. – musicamante Apr 12 '22 at 03:51
  • No worries! Moving the line.isValid() solved those problems for me, so I think it's OK. :) – Adam Sirrelle Apr 12 '22 at 04:25