Repository: pyapp-kit/superqt (language: python)

Issue:
Add elided `QLineEdit` class
Hi, while working on https://github.com/napari/napari/pull/5640 I ended up checking a way to add eliding capabilities to a `QLineEdit` at https://github.com/napari/napari/pull/5665 

Following the discussion (https://github.com/napari/napari/pull/5665#issuecomment-1483374558), could it be worthy to add here something like an [`ElidingLineEdit` class](https://github.com/napari/napari/blob/d9e3b5c604116cfcb8eeed2527ef803828172806/napari/_qt/widgets/qt_dims_slider.py#L31-L107)?


Discussion / hints:
sure, there is already an [ElidingLabel](https://github.com/pyapp-kit/superqt/blob/main/src/superqt/_eliding_label.py)

somehow the idea of an editable elided text feels a little funny from a UI perspective, (it's a hard problem of course, but I guess I imagine that one wants to always _see_ the thing they're editing, even if it will later be shown with ellipses somewhere).  but if you found a need for it in napari and want to upstream it here, by all means :)

Even better if you can combine the logic into a single base or mixin class?
Thank you for the quick response! I will look into having some base class for the label and the line edit :+1:

Following that, and regarding the possible code organization, maybe the file structure should look something like:

```
.
└── src/
    └── superqt/
        └── elidable/
            ├── __init__.py (exposes widgets with eliding capabilities)
            ├── _eliding_label.py (contains current ElidingLabel class refactored)
            ├── _eliding_line_edit.py (contains ElidingLineEdit class)
            └── _eliding.py (contains base/mixin class)

```
?
Sure!

Fix patch:
diff --git a/src/superqt/__init__.py b/src/superqt/__init__.py
index f779207e..c61bc2c8 100644
--- a/src/superqt/__init__.py
+++ b/src/superqt/__init__.py
@@ -9,9 +9,9 @@
 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 @@
     "QCollapsible",
     "QDoubleSlider",
     "QElidingLabel",
+    "QElidingLineEdit",
     "QEnumComboBox",
     "QLabeledDoubleRangeSlider",
     "QLabeledDoubleSlider",
diff --git a/src/superqt/_eliding_label.py b/src/superqt/_eliding_label.py
deleted file mode 100644
index 21eaddc1..00000000
--- a/src/superqt/_eliding_label.py
+++ /dev/null
@@ -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())
diff --git a/src/superqt/elidable/__init__.py b/src/superqt/elidable/__init__.py
new file mode 100644
index 00000000..1e4e587c
--- /dev/null
+++ b/src/superqt/elidable/__init__.py
@@ -0,0 +1,4 @@
+from ._eliding_label import QElidingLabel
+from ._eliding_line_edit import QElidingLineEdit
+
+__all__ = ["QElidingLabel", "QElidingLineEdit"]
diff --git a/src/superqt/elidable/_eliding.py b/src/superqt/elidable/_eliding.py
new file mode 100644
index 00000000..dedfcda0
--- /dev/null
+++ b/src/superqt/elidable/_eliding.py
@@ -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())
diff --git a/src/superqt/elidable/_eliding_label.py b/src/superqt/elidable/_eliding_label.py
new file mode 100644
index 00000000..369e193f
--- /dev/null
+++ b/src/superqt/elidable/_eliding_label.py
@@ -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())
diff --git a/src/superqt/elidable/_eliding_line_edit.py b/src/superqt/elidable/_eliding_line_edit.py
new file mode 100644
index 00000000..925bf525
--- /dev/null
+++ b/src/superqt/elidable/_eliding_line_edit.py
@@ -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


Test patch:
diff --git a/tests/test_eliding_label.py b/tests/test_eliding_label.py
index df6cd3f6..d3751eb7 100644
--- a/tests/test_eliding_label.py
+++ b/tests/test_eliding_label.py
@@ -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))
diff --git a/tests/test_eliding_line_edit.py b/tests/test_eliding_line_edit.py
new file mode 100644
index 00000000..7d227a8c
--- /dev/null
+++ b/tests/test_eliding_line_edit.py
@@ -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)