From 1fac492b0bb4f195fc612102fad4eb10127fa0c1 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 15 Jun 2026 16:14:11 +0200 Subject: [PATCH] feat(elide_label): general elide label --- bec_widgets/utils/eliding_label.py | 46 +++++++++++++++++++ .../beamline_states/beamline_state_pill.py | 9 ++-- tests/unit_tests/test_beamline_state_pill.py | 16 +++++++ tests/unit_tests/test_eliding_label.py | 43 +++++++++++++++++ 4 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 bec_widgets/utils/eliding_label.py create mode 100644 tests/unit_tests/test_eliding_label.py diff --git a/bec_widgets/utils/eliding_label.py b/bec_widgets/utils/eliding_label.py new file mode 100644 index 00000000..89ae7956 --- /dev/null +++ b/bec_widgets/utils/eliding_label.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from qtpy.QtCore import QSize, Qt +from qtpy.QtWidgets import QLabel, QSizePolicy, QWidget + + +class ElidingLabel(QLabel): + """A ``QLabel`` that elides its text with an ellipsis when too narrow to show it in full. + + ``QLabel`` itself has no elide support (only item views expose ``setTextElideMode``), so this + computes the elided string with ``QFontMetrics.elidedText`` and refreshes it on every resize. + + ``text()`` always returns the full, unelided text, so callers see the logical value while the + display shrinks. The label is allowed to shrink below its text width, so a long string never + forces its container wider or taller. + """ + + def __init__( + self, parent: QWidget | None = None, mode: Qt.TextElideMode = Qt.TextElideMode.ElideRight + ) -> None: + super().__init__(parent) + self._mode = mode + self._full_text = "" + self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) + + def setText(self, text: str) -> None: # noqa: N802 + self._full_text = text or "" + self._elide() + + def text(self) -> str: + return self._full_text + + def set_elide_mode(self, mode: Qt.TextElideMode) -> None: + """Set how the text is shortened (``ElideRight``/``ElideLeft``/``ElideMiddle``).""" + self._mode = mode + self._elide() + + def minimumSizeHint(self) -> QSize: # noqa: N802 + return QSize(0, super().minimumSizeHint().height()) + + def resizeEvent(self, event) -> None: # noqa: N802 + super().resizeEvent(event) + self._elide() + + def _elide(self) -> None: + super().setText(self.fontMetrics().elidedText(self._full_text, self._mode, self.width())) diff --git a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py index 77f7b610..6cccd015 100644 --- a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py @@ -24,6 +24,7 @@ from qtpy.QtWidgets import ( from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.colors import Colors, get_accent_colors, get_theme_name, rgba, theme_color +from bec_widgets.utils.eliding_label import ElidingLabel from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.forms_from_types.pydantic_widget_form import ( OptionalValueWidget, @@ -88,6 +89,9 @@ class BeamlineStatePill(BECWidget, QWidget): self.setObjectName("BeamlineStatePill") self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + # Floor below which the pill keeps its structure; the title/detail elide rather than + # pushing the pill wider or taller, so collapsed rows stay a consistent size. + self.setMinimumWidth(200) self._state_name: str | None = None self._state_config: messages.BeamlineStateConfig | None = None self._status = "unknown" @@ -129,7 +133,7 @@ class BeamlineStatePill(BECWidget, QWidget): self._icon_label.setFixedSize(32, 32) self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self._icon_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) - self._name_label = QLabel(self) + self._name_label = ElidingLabel(self) self._name_label.setObjectName("beamline_state_name") self._name_label.setTextFormat(Qt.TextFormat.PlainText) self._name_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) @@ -137,10 +141,9 @@ class BeamlineStatePill(BECWidget, QWidget): self._status_label.setObjectName("beamline_state_status") self._status_label.setTextFormat(Qt.TextFormat.PlainText) self._status_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) - self._detail_label = QLabel(self) + self._detail_label = ElidingLabel(self) self._detail_label.setObjectName("beamline_state_detail") self._detail_label.setTextFormat(Qt.TextFormat.PlainText) - self._detail_label.setWordWrap(True) self._detail_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) self._interlock_button = QToolButton(self) self._interlock_button.setObjectName("beamline_state_interlock") diff --git a/tests/unit_tests/test_beamline_state_pill.py b/tests/unit_tests/test_beamline_state_pill.py index 3bff8f9a..4088d42a 100644 --- a/tests/unit_tests/test_beamline_state_pill.py +++ b/tests/unit_tests/test_beamline_state_pill.py @@ -3,6 +3,7 @@ from bec_lib import bl_states, messages from qtpy.QtCore import QCoreApplication, QEvent, Qt from qtpy.QtWidgets import QMessageBox, QStyleOptionViewItem +from bec_widgets.utils.eliding_label import ElidingLabel from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.utils.widget_io import WidgetIO from bec_widgets.widgets.services.beamline_states import beamline_state_manager as manager_module @@ -227,6 +228,21 @@ def test_beamline_state_pill_does_not_override_themed_input_controls(qtbot, mock assert "QCheckBox::indicator" not in stylesheet +def test_beamline_state_pill_title_and_detail_elide_without_wrapping(qtbot, mocked_client): + pill = create_widget(qtbot, BeamlineStatePill, state_name="x", client=mocked_client) + + assert isinstance(pill._name_label, ElidingLabel) + assert isinstance(pill._detail_label, ElidingLabel) + # The detail no longer word-wraps, so a long message can't make a collapsed pill taller. + assert not pill._detail_label.wordWrap() + assert pill.minimumWidth() == 200 + + pill.update_state({"name": "x", "status": "valid", "label": "L" * 200}, {}) + + # A long title/detail keeps its full logical text instead of forcing the pill to grow. + assert pill._detail_label.text() == "L" * 200 + + def test_beamline_state_manager_adds_and_removes_pills(qtbot, mocked_client): beamline_state_manager = create_widget(qtbot, BeamlineStateManager, client=mocked_client) beamline_state_manager.update_available_states( diff --git a/tests/unit_tests/test_eliding_label.py b/tests/unit_tests/test_eliding_label.py new file mode 100644 index 00000000..8aa10152 --- /dev/null +++ b/tests/unit_tests/test_eliding_label.py @@ -0,0 +1,43 @@ +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QLabel + +from bec_widgets.utils.eliding_label import ElidingLabel + + +def test_eliding_label_keeps_full_text_but_elides_display(qtbot): + label = ElidingLabel() + qtbot.addWidget(label) + full = "a very long label text that will not fit in a narrow widget" + label.setText(full) + + label.resize(50, 20) + label._elide() + + # The logical value is preserved, while the rendered text is shortened with an ellipsis. + assert label.text() == full + assert QLabel.text(label) != full + assert QLabel.text(label).endswith("…") + + +def test_eliding_label_shows_full_text_when_wide_enough(qtbot): + label = ElidingLabel() + qtbot.addWidget(label) + label.setText("short") + + label.resize(400, 20) + label._elide() + + assert label.text() == "short" + assert QLabel.text(label) == "short" + + +def test_eliding_label_respects_elide_mode(qtbot): + label = ElidingLabel() + qtbot.addWidget(label) + label.setText("a very long label text that will not fit in a narrow widget") + label.resize(50, 20) + + label.set_elide_mode(Qt.TextElideMode.ElideMiddle) + + assert "…" in QLabel.text(label) + assert not QLabel.text(label).endswith("…")