From ba20665d57d6a39be588a009f9ef96828e08eda3 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 17 Aug 2021 11:03:57 -0400 Subject: [PATCH] Add QElidingLabel (#16) * wip * single class implementation * fix init * improve implementation * improve sizeHint * wrap * update docs * rename * remove overloads * review changes * docs and reformat * remove width from _elided text * add tests --- examples/eliding_label.py | 12 +++ superqt/__init__.py | 2 + superqt/_eliding_label.py | 110 +++++++++++++++++++++++++++ superqt/_tests/test_eliding_label.py | 68 +++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 examples/eliding_label.py create mode 100644 superqt/_eliding_label.py create mode 100644 superqt/_tests/test_eliding_label.py diff --git a/examples/eliding_label.py b/examples/eliding_label.py new file mode 100644 index 0000000..71fd7ff --- /dev/null +++ b/examples/eliding_label.py @@ -0,0 +1,12 @@ +from superqt import QElidingLabel +from superqt.qtcompat.QtWidgets import QApplication + +app = QApplication([]) + +widget = QElidingLabel( + "a skj skjfskfj sdlf sdfl sdlfk jsdf sdlkf jdsf dslfksdl sdlfk sdf sdl " + "fjsdlf kjsdlfk laskdfsal as lsdfjdsl kfjdslf asfd dslkjfldskf sdlkfj" +) +widget.setWordWrap(True) +widget.show() +app.exec_() diff --git a/superqt/__init__.py b/superqt/__init__.py index a02e7c4..6f0e5ee 100644 --- a/superqt/__init__.py +++ b/superqt/__init__.py @@ -5,6 +5,7 @@ except ImportError: __version__ = "unknown" +from ._eliding_label import QElidingLabel from .combobox import QEnumComboBox from .sliders import ( QDoubleRangeSlider, @@ -20,6 +21,7 @@ from .spinbox import QLargeIntSpinBox __all__ = [ "QDoubleRangeSlider", "QDoubleSlider", + "QElidingLabel", "QLabeledDoubleRangeSlider", "QLabeledDoubleSlider", "QLabeledRangeSlider", diff --git a/superqt/_eliding_label.py b/superqt/_eliding_label.py new file mode 100644 index 0000000..401b701 --- /dev/null +++ b/superqt/_eliding_label.py @@ -0,0 +1,110 @@ +from typing import List + +from superqt.qtcompat.QtCore import QPoint, QRect, QSize, Qt +from superqt.qtcompat.QtGui import QFont, QFontMetrics, QResizeEvent, QTextLayout +from superqt.qtcompat.QtWidgets import QLabel + + +class QElidingLabel(QLabel): + """A QLabel variant that will elide text (add '…') to fit width. + + QElidingLabel() + QElidingLabel(parent: Optional[QWidget], f: Qt.WindowFlags = ...) + QElidingLabel(text: str, parent: Optional[QWidget] = None, f: Qt.WindowFlags = ...) + + For a multiline eliding label, use `setWordWrap(True)`. In this case, text + will wrap to fit the width, and only the last line will be elided. + When `wordWrap()` is True, `sizeHint()` will return the size required to fit + the full text. + """ + + def __init__(self, *args, **kwargs) -> None: + self._elide_mode = Qt.TextElideMode.ElideRight + super().__init__(*args, **kwargs) + self.setText(args[0] if args and isinstance(args[0], str) else "") + + # New Public methods + + def elideMode(self) -> Qt.TextElideMode: + """The current Qt.TextElideMode.""" + return self._elide_mode + + def setElideMode(self, mode: Qt.TextElideMode): + """Set the elide mode to a Qt.TextElideMode.""" + self._elide_mode = Qt.TextElideMode(mode) + super().setText(self._elidedText()) + + @staticmethod + def wrapText(text, width, font=None) -> List[str]: + """Returns `text`, split as it would be wrapped for `width`, given `font`. + + Static method. + """ + tl = QTextLayout(text, font or QFont()) + tl.beginLayout() + lines = [] + while True: + ln = tl.createLine() + if not ln.isValid(): + break + ln.setLineWidth(width) + start = ln.textStart() + lines.append(text[start : start + ln.textLength()]) + tl.endLayout() + return lines + + # Reimplemented QT methods + + def text(self) -> str: + """This property holds the label's text. + + If no text has been set this will return an empty string. + """ + return self._text + + def setText(self, txt: str): + """Set the label's text. + + Setting the text clears any previous content. + NOTE: we set the QLabel private text to the elided version + """ + self._text = txt + super().setText(self._elidedText()) + + def resizeEvent(self, ev: QResizeEvent) -> None: + ev.accept() + super().setText(self._elidedText()) + + def setWordWrap(self, wrap: bool) -> None: + super().setWordWrap(wrap) + super().setText(self._elidedText()) + + def sizeHint(self) -> QSize: + if not self.wordWrap(): + return super().sizeHint() + fm = QFontMetrics(self.font()) + flags = self.alignment() | Qt.TextFlag.TextWordWrap + r = fm.boundingRect(QRect(QPoint(0, 0), self.size()), flags, self._text) + return QSize(self.width(), r.height()) + + # private implementation methods + + def _elidedText(self) -> str: + """Return `self._text` elided to `width`""" + fm = QFontMetrics(self.font()) + # the 2 is a magic number that prevents the ellipses from going missing + # in certain cases (?) + width = self.width() - 2 + if not self.wordWrap(): + return fm.elidedText(self._text, self._elide_mode, width) + + # get number of lines we can fit without eliding + nlines = self.height() // fm.height() - 1 + # get the last line (elided) + text = self._wrappedText() + last_line = fm.elidedText("".join(text[nlines:]), self._elide_mode, width) + # join them + return "".join(text[:nlines] + [last_line]) + + def _wrappedText(self) -> List[str]: + return QElidingLabel.wrapText(self._text, self.width(), self.font()) diff --git a/superqt/_tests/test_eliding_label.py b/superqt/_tests/test_eliding_label.py new file mode 100644 index 0000000..9660680 --- /dev/null +++ b/superqt/_tests/test_eliding_label.py @@ -0,0 +1,68 @@ +from superqt import QElidingLabel +from superqt.qtcompat.QtCore import QSize, Qt +from superqt.qtcompat.QtGui import QResizeEvent + +TEXT = ( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " + "eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad " + "minim ven iam, quis nostrud exercitation ullamco laborisnisi ut aliquip " + "ex ea commodo consequat. Duis aute irure dolor inreprehenderit in voluptate " + "velit esse cillum dolore eu fugiat nullapariatur." +) +ELLIPSIS = "…" + + +def test_eliding_label(qtbot): + wdg = QElidingLabel(TEXT) + qtbot.addWidget(wdg) + assert wdg._elidedText().endswith(ELLIPSIS) + oldsize = wdg.size() + newsize = QSize(200, 20) + wdg.resize(newsize) + wdg.resizeEvent(QResizeEvent(oldsize, newsize)) # for test coverage + assert wdg.text() == TEXT + + +def test_wrapped_eliding_label(qtbot): + wdg = QElidingLabel(TEXT) + qtbot.addWidget(wdg) + assert not wdg.wordWrap() + assert wdg.sizeHint() == QSize(633, 16) + assert wdg._elidedText() == ( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " + "eiusmod tempor incididunt ut labore et d…" + ) + wdg.resize(QSize(200, 100)) + assert wdg.text() == TEXT + assert wdg._elidedText() == "Lorem ipsum dolor sit amet, co…" + wdg.setWordWrap(True) + assert wdg.wordWrap() + assert wdg.text() == TEXT + assert wdg._elidedText() == ( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " + "eiusmod tempor incididunt ut labore et dolore magna aliqua. " + "Ut enim ad minim ven iam, quis nostrud exercitation ullamco la…" + ) + assert wdg.sizeHint() == QSize(200, 176) + wdg.resize(wdg.sizeHint()) + assert wdg._elidedText() == TEXT + + +def test_shorter_eliding_label(qtbot): + short = "asd a ads sd flksdf dsf lksfj sd lsdjf sd lsdfk sdlkfj s" + wdg = QElidingLabel() + qtbot.addWidget(wdg) + wdg.setText(short) + assert not wdg._elidedText().endswith(ELLIPSIS) + wdg.resize(100, 20) + assert wdg._elidedText().endswith(ELLIPSIS) + wdg.setElideMode(Qt.TextElideMode.ElideLeft) + assert wdg._elidedText().startswith(ELLIPSIS) + assert wdg.elideMode() == Qt.TextElideMode.ElideLeft + + +def test_wrap_text(): + wrap = QElidingLabel.wrapText(TEXT, 200) + assert isinstance(wrap, list) + assert all(isinstance(x, str) for x in wrap) + assert len(wrap) == 11