Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

PySide/PyQt truncate text in QLabel based on minimumSize

Tags:

qt

pyqt4

pyside

I am wondering how to best truncate text in a QLabel based on it's maximum width/height. The incoming text could be any length, but in order to keep a tidy layout I'd like to truncate long strings to fill a maximum amount of space (widget's maximum width/height).

E.g.:

 'A very long string where there should only be a short one, but I can't control input to the widget as it's a user given value'

would become:

'A very long string where there should only be a short one, but ...'

based on the required space the current font needs.

How can I achieve this best?

Here is a simple example of what I'm after, though this is based on word count, not available space:

import sys
from PySide.QtGui import *
from PySide.QtCore import *


def truncateText(text):
    maxWords = 10
    words = text.split(' ')
    return ' '.join(words[:maxWords]) + ' ...'

app = QApplication(sys.argv)

mainWindow = QWidget()
layout = QHBoxLayout()
mainWindow.setLayout(layout)

text = 'this is a very long string, '*10
label = QLabel(truncateText(text))
label.setWordWrap(True)
label.setFixedWidth(200)
layout.addWidget(label)

mainWindow.show()
sys.exit(app.exec_())
like image 902
Frank Rueter Avatar asked Jul 12 '12 06:07

Frank Rueter


2 Answers

Even easier - use the QFontMetrics.elidedText method and overload the paintEvent, here's an example:

from PyQt4.QtCore import Qt
from PyQt4.QtGui import QApplication,\
                        QLabel,\
                        QFontMetrics,\
                        QPainter

class MyLabel(QLabel):
    def paintEvent( self, event ):
        painter = QPainter(self)

        metrics = QFontMetrics(self.font())
        elided  = metrics.elidedText(self.text(), Qt.ElideRight, self.width())

        painter.drawText(self.rect(), self.alignment(), elided)

if ( __name__ == '__main__' ):
    app = None
    if ( not QApplication.instance() ):
        app = QApplication([])

    label = MyLabel()
    label.setText('This is a really, long and poorly formatted runon sentence used to illustrate a point')
    label.setWindowFlags(Qt.Dialog)
    label.show()

    if ( app ):
        app.exec_()
like image 53
Eric Hulser Avatar answered Dec 08 '22 06:12

Eric Hulser


I found that @Eric Hulser's answer, while great, didn't work when the label was put into another widget.

I came up with this by hacking together Eric's response with the Qt Elided Label Example. It should behave just like a regular label, yet elide horizontally when the text width exceeds the widget width. It has an extra argument for different elide modes. I also wrote some tests for fun :)

If you want to use PyQt5...

  • Change "PySide2" to "PyQt5"
  • Change "Signal" to "pyqtSignal"

Enjoy!

Eliding Label

# eliding_label.py

from PySide2 import QtCore, QtWidgets, QtGui


class ElidingLabel(QtWidgets.QLabel):
    """Label with text elision.

    QLabel which will elide text too long to fit the widget.  Based on:
    https://doc-snapshots.qt.io/qtforpython-5.15/overviews/qtwidgets-widgets-elidedlabel-example.html

    Parameters
    ----------
    text : str

        Label text.

    mode : QtCore.Qt.TextElideMode

       Specify where ellipsis should appear when displaying texts that
       don’t fit.

       Default is QtCore.Qt.ElideMiddle.

       Possible modes:
         QtCore.Qt.ElideLeft
         QtCore.Qt.ElideMiddle
         QtCore.Qt.ElideRight

    parent : QWidget

       Parent widget.  Default is None.

    f : Qt.WindowFlags()

       https://doc-snapshots.qt.io/qtforpython-5.15/PySide2/QtCore/Qt.html#PySide2.QtCore.PySide2.QtCore.Qt.WindowType

    """

    elision_changed = QtCore.Signal(bool)

    def __init__(self, text='', mode=QtCore.Qt.ElideMiddle, **kwargs):
        super().__init__(**kwargs)

        self._mode = mode
        self.is_elided = False
        self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Preferred)
        self.setText(text)

    def setText(self, text):
        self._contents    = text

        # This line set for testing.  Its value is the return value of
        # QFontMetrics.elidedText, set in paintEvent.  The variable
        # must be initialized for testing.  The value should always be
        # the same as contents when not elided.
        self._elided_line = text

        self.update()

    def text(self):
        return self._contents

    def paintEvent(self, event):
        super().paintEvent(event)

        did_elide = False

        painter = QtGui.QPainter(self)
        font_metrics = painter.fontMetrics()
        text_width = font_metrics.horizontalAdvance(self.text())

        # layout phase
        text_layout = QtGui.QTextLayout(self._contents, painter.font())
        text_layout.beginLayout()

        while True:
            line = text_layout.createLine()

            if not line.isValid():
                break

            line.setLineWidth(self.width())

            if text_width >= self.width():
                self._elided_line = font_metrics.elidedText(self._contents, self._mode, self.width())
                painter.drawText(QtCore.QPoint(0, font_metrics.ascent()), self._elided_line)
                did_elide = line.isValid()
                break
            else:
                line.draw(painter, QtCore.QPoint(0, 0))

        text_layout.endLayout()

        if did_elide != self.is_elided:
            self.is_elided = did_elide
            self.elision_changed.emit(did_elide)


if __name__ == '__main__':
    app = QtWidgets.QApplication([])
    long_text = "this is some long text, wouldn't you say?"
    elabel = ElidingLabel(long_text)
    elabel.show()
    app.exec_()

Test Eliding Label

# test_eliding_label.py.py
#
# Run tests with
#
#   python3 -m unittest test_eliding_label.py --failfast --quiet

import unittest
import unittest.mock
from PySide2 import QtCore, QtWidgets, QtGui, QtTest
import eliding_label


if not QtWidgets.QApplication.instance():
    APP = QtWidgets.QApplication([])  # pragma: no cover


class TestElidingLabelArguments(unittest.TestCase):

    def test_optional_text_argument(self):
        elabel = eliding_label.ElidingLabel()
        self.assertEqual(elabel.text(), "")

    def test_text_argument_sets_label_text(self):
        elabel = eliding_label.ElidingLabel(text="Test text")
        self.assertEqual(elabel.text(), "Test text")

    def test_optional_elision_mode_argument(self):
        elabel = eliding_label.ElidingLabel()
        self.assertEqual(elabel._mode, QtCore.Qt.ElideMiddle)

class TestElidingLabel(unittest.TestCase):

    def setUp(self):
        self.elabel = eliding_label.ElidingLabel()

    def test_elabel_is_a_label(self):
        self.assertIsInstance(self.elabel, QtWidgets.QLabel)

    def test_has_elision_predicate(self):
        self.assertEqual(self.elabel.is_elided, False)

    def test_elision_predicate_changes_when_text_width_exceeds_widget_width(self):
        # NOTE: This is a bit of a stretch, inducing a paint event
        # when the event loop isn't running.  Throws a bunch of C++
        # sourced text which can't be (easily) caught.
        self.elabel.setFixedWidth(25)
        self.assertEqual(self.elabel.width(), 25)

        long_text = "This is line is definely longer than 25 pixels."
        painter = QtGui.QPainter()
        font_metrics = painter.fontMetrics()
        long_text_width = font_metrics.horizontalAdvance(long_text)
        self.assertGreater(long_text_width, 25)

        self.elabel.setText(long_text)

        x = self.elabel.x()
        y = self.elabel.y()
        w = self.elabel.width()
        h = self.elabel.height()
        paint_event = QtGui.QPaintEvent(QtGui.QRegion(x, y, w, h))
        self.elabel.paintEvent(paint_event)

        self.assertEqual(self.elabel.is_elided, True)

    def test_text_is_elided_when_text_width_exceeds_widget_width(self):
        # NOTE: This is a bit of a stretch, inducing a paint event
        # when the event loop isn't running.  Throws a bunch of C++
        # sourced text which can't be (easily) caught.
        self.elabel.setFixedWidth(25)
        self.assertEqual(self.elabel.width(), 25)

        long_text = "This is line is definely longer than 25 pixels."
        painter = QtGui.QPainter()
        font_metrics = painter.fontMetrics()
        long_text_width = font_metrics.horizontalAdvance(long_text)
        self.assertGreater(long_text_width, 25)

        self.elabel.setText(long_text)

        x = self.elabel.x()
        y = self.elabel.y()
        w = self.elabel.width()
        h = self.elabel.height()
        paint_event = QtGui.QPaintEvent(QtGui.QRegion(x, y, w, h))
        self.elabel.paintEvent(paint_event)

        # PySide2.QtGui.QFontMetrics.elidedText states, "If the string
        # text is wider than width , returns an elided version of the
        # string (i.e., a string with '…' in it). Otherwise, returns
        # the original string."
        self.assertEqual(self.elabel._elided_line, '…')

    def test_text_is_not_elided_when_text_width_is_less_than_widget_width(self):
        # NOTE: This is a bit of a stretch, inducing a paint event
        # when the event loop isn't running.  Throws a bunch of C++
        # sourced text which can't be (easily) caught.
        self.elabel.setFixedWidth(500)
        self.assertEqual(self.elabel.width(), 500)

        short_text = "Less than 500"
        painter = QtGui.QPainter()
        font_metrics = painter.fontMetrics()
        short_text_width = font_metrics.horizontalAdvance(short_text)
        self.assertLess(short_text_width, 500)

        self.elabel.setText(short_text)

        x = self.elabel.x()
        y = self.elabel.y()
        w = self.elabel.width()
        h = self.elabel.height()
        paint_event = QtGui.QPaintEvent(QtGui.QRegion(x, y, w, h))
        self.elabel.paintEvent(paint_event)

        # PySide2.QtGui.QFontMetrics.elidedText states, "If the string
        # text is wider than width , returns an elided version of the
        # string (i.e., a string with '…' in it). Otherwise, returns
        # the original string."
        self.assertEqual(self.elabel._elided_line, short_text)

    def test_stores_full_text_even_when_elided(self):
        # NOTE: This is a bit of a stretch, inducing a paint event
        # when the event loop isn't running.  Throws a bunch of C++
        # sourced text which can't be (easily) caught.
        self.elabel.setFixedWidth(25)
        self.assertEqual(self.elabel.width(), 25)

        long_text = "This is line is definely longer than 25 pixels."
        painter = QtGui.QPainter()
        font_metrics = painter.fontMetrics()
        long_text_width = font_metrics.horizontalAdvance(long_text)
        self.assertGreater(long_text_width, 25)

        self.elabel.setText(long_text)

        x = self.elabel.x()
        y = self.elabel.y()
        w = self.elabel.width()
        h = self.elabel.height()
        paint_event = QtGui.QPaintEvent(QtGui.QRegion(x, y, w, h))
        self.elabel.paintEvent(paint_event)

        # PySide2.QtGui.QFontMetrics.elidedText states, "If the string
        # text is wider than width , returns an elided version of the
        # string (i.e., a string with '…' in it). Otherwise, returns
        # the original string."
        self.assertEqual(self.elabel._elided_line, '…')
        self.assertEqual(self.elabel.text(), long_text)

    def test_has_elision_changed_signal(self):
        self.assertIsInstance(self.elabel.elision_changed, QtCore.Signal)

    def test_elision_changed_signal_emits_on_change_to_is_elided_predicate(self):
        mock = unittest.mock.Mock()
        self.elabel.elision_changed.connect(mock.method)

        # NOTE: This is a bit of a stretch, inducing a paint event
        # when the event loop isn't running.  Throws a bunch of C++
        # sourced text which can't be (easily) caught.

        # Induce elision
        self.elabel.setFixedWidth(150)
        self.assertEqual(self.elabel.width(), 150)

        long_text = "This line is definitely going to be more than 150 pixels"
        painter = QtGui.QPainter()
        font_metrics = painter.fontMetrics()
        long_text_width = font_metrics.horizontalAdvance(long_text)
        self.assertGreater(long_text_width, 150)

        self.elabel.setText(long_text)
        self.assertEqual(self.elabel.is_elided, False)  # no elide until painting

        x = self.elabel.x()
        y = self.elabel.y()
        w = self.elabel.width()
        h = self.elabel.height()
        paint_event = QtGui.QPaintEvent(QtGui.QRegion(x, y, w, h))
        self.elabel.paintEvent(paint_event)

        self.assertEqual(self.elabel.is_elided, True)

        mock.method.assert_called_once()

        # Remove elision
        short_text = "Less than 150"
        painter = QtGui.QPainter()
        font_metrics = painter.fontMetrics()
        short_text_width = font_metrics.horizontalAdvance(short_text)
        self.assertLess(short_text_width, 150)

        self.elabel.setText(short_text)
        self.assertEqual(self.elabel.is_elided, True)  # still elided until painting

        x = self.elabel.x()
        y = self.elabel.y()
        w = self.elabel.width()
        h = self.elabel.height()
        paint_event = QtGui.QPaintEvent(QtGui.QRegion(x, y, w, h))
        self.elabel.paintEvent(paint_event)

        self.assertEqual(self.elabel.is_elided, False)

        self.assertEqual(mock.method.call_count, 2)
like image 25
Lorem Ipsum Avatar answered Dec 08 '22 05:12

Lorem Ipsum