Add QElidingLineEdit class for elidable QLineEdits (#154)

* Add QElidingLineEdit class for elidable QLineEdits

* Fix QElidingLineEdit tests on Linux and MacOS

* Testing

---------

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
This commit is contained in:
Daniel Althviz Moré
2023-08-03 08:08:12 -07:00
committed by GitHub
parent 6318675a8c
commit 9119336de5
8 changed files with 313 additions and 114 deletions

View File

@@ -9,9 +9,9 @@ except ImportError:
if TYPE_CHECKING:
from .spinbox._quantity import QQuantity
from ._eliding_label import QElidingLabel
from .collapsible import QCollapsible
from .combobox import QEnumComboBox, QSearchableComboBox
from .elidable import QElidingLabel, QElidingLineEdit
from .selection import QSearchableListWidget, QSearchableTreeWidget
from .sliders import (
QDoubleRangeSlider,
@@ -32,6 +32,7 @@ __all__ = [
"QCollapsible",
"QDoubleSlider",
"QElidingLabel",
"QElidingLineEdit",
"QEnumComboBox",
"QLabeledDoubleRangeSlider",
"QLabeledDoubleSlider",

View File

@@ -1,110 +0,0 @@
from typing import List
from qtpy.QtCore import QPoint, QRect, QSize, Qt
from qtpy.QtGui import QFont, QFontMetrics, QResizeEvent, QTextLayout
from qtpy.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:
"""Return 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 = int(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())

View File

@@ -0,0 +1,4 @@
from ._eliding_label import QElidingLabel
from ._eliding_line_edit import QElidingLineEdit
__all__ = ["QElidingLabel", "QElidingLineEdit"]

View File

@@ -0,0 +1,78 @@
from typing import List
from qtpy.QtCore import Qt
from qtpy.QtGui import QFont, QFontMetrics, QTextLayout
class _GenericEliding:
"""A mixin to provide capabilities to elide text (could add '') to fit width."""
_elide_mode: Qt.TextElideMode = Qt.TextElideMode.ElideRight
_text: str = ""
# the 2 is a magic number that prevents the ellipses from going missing
# in certain cases (?)
_ellipses_width: int = 2
# Public methods
def elideMode(self) -> Qt.TextElideMode:
"""The current Qt.TextElideMode."""
return self._elide_mode
def setElideMode(self, mode: Qt.TextElideMode) -> None:
"""Set the elide mode to a Qt.TextElideMode."""
self._elide_mode = Qt.TextElideMode(mode)
def full_text(self) -> str:
"""The current text without eliding."""
return self._text
def setEllipsesWidth(self, width: int) -> None:
"""A width value to take into account ellipses width when eliding text.
The value is deducted from the widget width when computing the elided version
of the text.
"""
self._ellipses_width = width
@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
# private implementation methods
def _elidedText(self) -> str:
"""Return `self._text` elided to `width`."""
fm = QFontMetrics(self.font())
ellipses_width = 0
if self._elide_mode != Qt.TextElideMode.ElideNone:
ellipses_width = self._ellipses_width
width = self.width() - ellipses_width
if not getattr(self, "wordWrap", None) or 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 _GenericEliding.wrapText(self._text, self.width(), self.font())

View File

@@ -0,0 +1,75 @@
from qtpy.QtCore import QPoint, QRect, QSize, Qt
from qtpy.QtGui import QFontMetrics, QResizeEvent
from qtpy.QtWidgets import QLabel
from ._eliding import _GenericEliding
class QElidingLabel(_GenericEliding, QLabel):
"""
A QLabel variant that will elide text (could 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:
super().__init__(*args, **kwargs)
if args and isinstance(args[0], str):
self.setText(args[0])
# Reimplemented _GenericEliding methods
def setElideMode(self, mode: Qt.TextElideMode) -> None:
"""Set the elide mode to a Qt.TextElideMode."""
super().setElideMode(mode)
super().setText(self._elidedText())
def setEllipsesWidth(self, width: int) -> None:
"""A width value to take into account ellipses width when eliding text.
The value is deducted from the widget width when computing the elided version
of the text.
"""
super().setEllipsesWidth(width)
super().setText(self._elidedText())
# Reimplemented QT methods
def text(self) -> str:
"""Return the label's text.
If no text has been set this will return an empty string.
"""
return self._text
def setText(self, txt: str) -> None:
"""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, event: QResizeEvent) -> None:
event.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 = int(self.alignment() | Qt.TextFlag.TextWordWrap)
r = fm.boundingRect(QRect(QPoint(0, 0), self.size()), flags, self._text)
return QSize(self.width(), r.height())

View File

@@ -0,0 +1,91 @@
from qtpy.QtCore import Qt
from qtpy.QtGui import QFocusEvent, QResizeEvent
from qtpy.QtWidgets import QLineEdit
from ._eliding import _GenericEliding
class QElidingLineEdit(_GenericEliding, QLineEdit):
"""A QLineEdit variant that will elide text (could add '') to fit width.
QElidingLineEdit()
QElidingLineEdit(parent: Optional[QWidget])
QElidingLineEdit(text: str, parent: Optional[QWidget] = None)
"""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
if args and isinstance(args[0], str):
self.setText(args[0])
# The `textEdited` signal doesn't trigger the `textChanged` signal if
# text is changed with `setText`, so we connect to `textEdited` to only
# update _text when text is being edited by the user graphically.
self.textEdited.connect(self._update_text)
# Reimplemented _GenericEliding methods
def setElideMode(self, mode: Qt.TextElideMode) -> None:
"""Set the elide mode to a Qt.TextElideMode.
The text shown is updated to the elided version only if the widget is not
focused.
"""
super().setElideMode(mode)
if not self.hasFocus():
super().setText(self._elidedText())
def setEllipsesWidth(self, width: int) -> None:
"""A width value to take into account ellipses width when eliding text.
The value is deducted from the widget width when computing the elided version
of the text. The text shown is updated to the elided version only if the widget
is not focused.
"""
super().setEllipsesWidth(width)
if not self.hasFocus():
super().setText(self._elidedText())
# Reimplemented QT methods
def text(self) -> str:
"""Return the label's text being shown.
If no text has been set this will return an empty string.
"""
return self._text
def setText(self, text) -> None:
"""Set the line edit's text.
Setting the text clears any previous content.
NOTE: we set the QLineEdit private text to the elided version
"""
self._text = text
if not self.hasFocus():
super().setText(self._elidedText())
def focusInEvent(self, event: QFocusEvent) -> None:
"""Set the full text when the widget is focused."""
super().setText(self._text)
super().focusInEvent(event)
def focusOutEvent(self, event: QFocusEvent) -> None:
"""Set an elided version of the text (if needed) when the focus is out."""
super().setText(self._elidedText())
super().focusOutEvent(event)
def resizeEvent(self, event: QResizeEvent) -> None:
"""Update elided text being shown when the widget is resized."""
if not self.hasFocus():
super().setText(self._elidedText())
super().resizeEvent(event)
# private implementation methods
def _update_text(self, text: str) -> None:
"""Update only the actual text of the widget.
The actual text is the text the widget has without eliding.
"""
self._text = text

View File

@@ -31,14 +31,14 @@ def test_wrapped_eliding_label(qtbot):
qtbot.addWidget(wdg)
assert not wdg.wordWrap()
assert 630 < wdg.sizeHint().width() < 640
assert wdg._elidedText().endswith("")
assert wdg._elidedText().endswith(ELLIPSIS)
wdg.resize(QSize(200, 100))
assert wdg.text() == TEXT
assert wdg._elidedText().endswith("")
assert wdg._elidedText().endswith(ELLIPSIS)
wdg.setWordWrap(True)
assert wdg.wordWrap()
assert wdg.text() == TEXT
assert wdg._elidedText().endswith("")
assert wdg._elidedText().endswith(ELLIPSIS)
# just empirically from CI ... stupid
if platform.system() == "Linux":
assert wdg.sizeHint() in (QSize(200, 198), QSize(200, 154))

View File

@@ -0,0 +1,60 @@
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QResizeEvent
from superqt import QElidingLineEdit
TEXT = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do"
ELLIPSIS = ""
def test_init_text_eliding_line_edit(qtbot):
wdg = QElidingLineEdit(TEXT)
qtbot.addWidget(wdg)
oldsize = QSize(100, 20)
wdg.resize(oldsize)
assert wdg._elidedText().endswith(ELLIPSIS)
newsize = QSize(500, 20)
wdg.resize(newsize)
wdg.resizeEvent(QResizeEvent(oldsize, newsize)) # for test coverage
assert wdg._elidedText() == TEXT
assert wdg.text() == TEXT
def test_set_text_eliding_line_edit(qtbot):
wdg = QElidingLineEdit()
qtbot.addWidget(wdg)
wdg.resize(500, 20)
wdg.setText(TEXT)
assert not wdg._elidedText().endswith(ELLIPSIS)
wdg.resize(100, 20)
assert wdg._elidedText().endswith(ELLIPSIS)
def test_set_elide_mode_eliding_line_edit(qtbot):
wdg = QElidingLineEdit()
qtbot.addWidget(wdg)
wdg.resize(500, 20)
wdg.setText(TEXT)
assert not wdg._elidedText().endswith(ELLIPSIS)
wdg.resize(100, 20)
# ellipses should be to the right
assert wdg._elidedText().endswith(ELLIPSIS)
# ellipses should be to the left
wdg.setElideMode(Qt.TextElideMode.ElideLeft)
assert wdg._elidedText().startswith(ELLIPSIS)
assert wdg.elideMode() == Qt.TextElideMode.ElideLeft
# no ellipses should be shown
wdg.setElideMode(Qt.TextElideMode.ElideNone)
assert ELLIPSIS not in wdg._elidedText()
def test_set_elipses_width_eliding_line_edit(qtbot):
wdg = QElidingLineEdit()
qtbot.addWidget(wdg)
wdg.resize(500, 20)
wdg.setText(TEXT)
assert not wdg._elidedText().endswith(ELLIPSIS)
wdg.setEllipsesWidth(int(wdg.width() / 2))
assert wdg._elidedText().endswith(ELLIPSIS)