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.
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!
CodePudding user response:
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)