From 49268e3829406d70b09e4d88989812f5578e46f4 Mon Sep 17 00:00:00 2001 From: Mathias Guijarro Date: Thu, 3 Oct 2024 16:10:28 +0200 Subject: [PATCH] feat: add 'CompactPopupWidget' container widget Makes it easy to write widgets which can have a compact representation with LED-like global state indicator, with the possibility to display a popup dialog with more complete UI --- bec_widgets/qt_utils/compact_popup.py | 223 ++++++++++++++++++ tests/unit_tests/test_compact_popup_widget.py | 66 ++++++ 2 files changed, 289 insertions(+) create mode 100644 bec_widgets/qt_utils/compact_popup.py create mode 100644 tests/unit_tests/test_compact_popup_widget.py diff --git a/bec_widgets/qt_utils/compact_popup.py b/bec_widgets/qt_utils/compact_popup.py new file mode 100644 index 00000000..48c68897 --- /dev/null +++ b/bec_widgets/qt_utils/compact_popup.py @@ -0,0 +1,223 @@ +from types import SimpleNamespace + +from bec_qthemes import material_icon +from qtpy.QtCore import Property, Qt +from qtpy.QtGui import QColor +from qtpy.QtWidgets import ( + QDialog, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.colors import get_accent_colors + + +class LedLabel(QLabel): + success_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.145, y1:0.16, x2:1, y2:1, stop:0 %s, stop:1 %s);" + emergency_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.145, y1:0.16, x2:0.92, y2:0.988636, stop:0 %s, stop:1 %s);" + warning_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.232, y1:0.272, x2:0.98, y2:0.959773, stop:0 %s, stop:1 %s);" + default_led = "color: white;border-radius: 10;background-color: qlineargradient(spread:pad, x1:0.04, y1:0.0565909, x2:0.799, y2:0.795, stop:0 %s, stop:1 %s);" + + def __init__(self, parent=None): + super().__init__(parent) + + self.palette = get_accent_colors() + if self.palette is None: + # no theme! + self.palette = SimpleNamespace( + default=QColor("blue"), + success=QColor("green"), + warning=QColor("orange"), + emergency=QColor("red"), + ) + self.setState("default") + self.setFixedSize(20, 20) + + def setState(self, state: str): + match state: + case "success": + r, g, b, a = self.palette.success.getRgb() + self.setStyleSheet( + LedLabel.success_led + % ( + f"rgba({r},{g},{b},{a})", + f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})", + ) + ) + case "default": + r, g, b, a = self.palette.default.getRgb() + self.setStyleSheet( + LedLabel.default_led + % ( + f"rgba({r},{g},{b},{a})", + f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})", + ) + ) + case "warning": + r, g, b, a = self.palette.warning.getRgb() + self.setStyleSheet( + LedLabel.warning_led + % ( + f"rgba({r},{g},{b},{a})", + f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})", + ) + ) + case "emergency": + r, g, b, a = self.palette.emergency.getRgb() + self.setStyleSheet( + LedLabel.emergency_led + % ( + f"rgba({r},{g},{b},{a})", + f"rgba({int(r*0.8)},{int(g*0.8)},{int(b*0.8)},{a})", + ) + ) + case unknown_state: + raise ValueError( + f"Unknown state {repr(unknown_state)}, must be one of default, success, warning or emergency" + ) + + +class PopupDialog(QDialog): + def __init__(self, content_widget): + self.parent = content_widget.parent() + self.content_widget = content_widget + + super().__init__(self.parent) + + self.setAttribute(Qt.WA_DeleteOnClose) + + self.content_widget.setParent(self) + QVBoxLayout(self) + self.layout().addWidget(self.content_widget) + self.content_widget.setVisible(True) + + def closeEvent(self, event): + self.content_widget.setVisible(False) + self.content_widget.setParent(self.parent) + + +class CompactPopupWidget(QWidget): + """Container widget, that can display its content or have a compact form, + in this case clicking on a small button pops the contained widget up. + + In the compact form, a LED-like indicator shows a status indicator. + """ + + def __init__(self, parent=None, layout=QVBoxLayout): + super().__init__(parent) + + self._popup_window = None + + QVBoxLayout(self) + self.compact_view = QWidget(self) + self.compact_view.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + QHBoxLayout(self.compact_view) + self.compact_view.layout().setSpacing(0) + self.compact_view.layout().setContentsMargins(0, 0, 0, 0) + self.compact_label = QLabel(self.compact_view) + self.compact_status = LedLabel(self.compact_view) + self.compact_show_popup = QPushButton(self.compact_view) + self.compact_show_popup.setFlat(True) + self.compact_show_popup.setIcon( + material_icon(icon_name="pan_zoom", size=(10, 10), convert_to_pixmap=False) + ) + self.compact_view.layout().addWidget(self.compact_label) + self.compact_view.layout().addWidget(self.compact_status) + self.compact_view.layout().addWidget(self.compact_show_popup) + self.compact_view.setVisible(False) + self.layout().addWidget(self.compact_view) + self.container = QWidget(self) + self.layout().addWidget(self.container) + self.container.setVisible(True) + layout(self.container) + self.layout = self.container.layout() + + self.compact_show_popup.clicked.connect(self.show_popup) + + def set_global_state(self, state: str): + """Set the LED-indicator state + + The LED indicator represents the 'global' state. State can be one of the + following: "default", "success", "warning", "emergency" + """ + self.compact_status.setState(state) + + def show_popup(self): + """Display the contained widgets in a popup dialog""" + self._popup_window = PopupDialog(self.container) + self._popup_window.show() + + def setSizePolicy(self, size_policy1, size_policy2=None): + # setting size policy on the compact popup widget will set + # the policy for the container, and for itself + if size_policy2 is None: + # assuming first form: setSizePolicy(QSizePolicy) + self.container.setSizePolicy(size_policy1) + QWidget.setSizePolicy(self, size_policy1) + else: + self.container.setSizePolicy(size_policy1, size_policy2) + QWidget.setSizePolicy(self, size_policy1, size_policy2) + + def addWidget(self, widget): + """Add a widget to the popup container + + The popup container corresponds to the "full view" (not compact) + The widget is reparented to the container, and added to the container layout + """ + widget.setParent(self.container) + self.container.layout().addWidget(widget) + + @Property(bool) + def compact(self): + return self.compact_view.isVisible() + + @compact.setter + def compact(self, set_compact: bool): + """Sets the compact form + + If set_compact is True, the compact view is displayed ; otherwise, + the full view is displayed. This is handled by toggling visibility of + the container widget or the compact view widget. + """ + if set_compact: + self.compact_view.setVisible(True) + self.container.setVisible(False) + QWidget.setSizePolicy(self, QSizePolicy.Fixed, QSizePolicy.Fixed) + else: + self.compact_view.setVisible(False) + self.container.setVisible(True) + QWidget.setSizePolicy(self, self.container.sizePolicy()) + if self.parentWidget(): + self.parentWidget().adjustSize() + else: + self.adjustSize() + + @Property(str) + def label(self): + return self.compact_label.text() + + @label.setter + def label(self, compact_label_text: str): + """Set the label text associated to the compact view""" + self.compact_label.setText(compact_label_text) + + @Property(str) + def tooltip(self): + return self.compact_label.toolTip() + + @tooltip.setter + def tooltip(self, tooltip: str): + """Set the tooltip text associated to the compact view""" + self.compact_label.setToolTip(tooltip) + self.compact_status.setToolTip(tooltip) + + def closeEvent(self, event): + # Called by Qt, on closing - since the children widgets can be + # BECWidgets, it is good to explicitely call 'close' on them, + # to ensure proper resources cleanup + for child in self.container.findChildren(QWidget, options=Qt.FindDirectChildrenOnly): + child.close() diff --git a/tests/unit_tests/test_compact_popup_widget.py b/tests/unit_tests/test_compact_popup_widget.py new file mode 100644 index 00000000..cc744d10 --- /dev/null +++ b/tests/unit_tests/test_compact_popup_widget.py @@ -0,0 +1,66 @@ +# pylint: skip-file +from unittest import mock + +import pytest +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget + +from bec_widgets.qt_utils.compact_popup import CompactPopupWidget + + +class ContainedWidget(QWidget): + def __init__(self, parent): + super().__init__(parent) + + +class TestCompactPopupWidget(CompactPopupWidget): + def __init__(self): + super().__init__(layout=QVBoxLayout) + + self.contained = QWidget(self) + self.addWidget(self.contained) + self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) + + +@pytest.fixture +def compact_popup(qtbot): + widget = TestCompactPopupWidget() + qtbot.addWidget(widget) + widget.show() + qtbot.wait_until(widget.isVisible) + yield widget + + +def test_widget_closing(qtbot, compact_popup): + with mock.patch.object(compact_popup.contained, "close") as close_method: + compact_popup.close() + qtbot.waitUntil(lambda: not compact_popup.isVisible(), timeout=1000) + close_method.assert_called_once() + + +def test_size_policy(compact_popup): + csp = compact_popup.sizePolicy() + assert csp.horizontalPolicy() == QSizePolicy.Expanding + assert csp.verticalPolicy() == QSizePolicy.Minimum + compact_popup.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Expanding) + csp = compact_popup.sizePolicy() + assert csp.horizontalPolicy() == QSizePolicy.Minimum + assert csp.verticalPolicy() == QSizePolicy.Expanding + compact_popup.compact = True + csp = compact_popup.sizePolicy() + assert csp.horizontalPolicy() == QSizePolicy.Fixed + assert csp.verticalPolicy() == QSizePolicy.Fixed + compact_popup.compact = False + csp = compact_popup.sizePolicy() + assert csp.horizontalPolicy() == QSizePolicy.Minimum + assert csp.verticalPolicy() == QSizePolicy.Expanding + + +def test_open_full_view(qtbot, compact_popup): + qtbot.waitUntil(compact_popup.container.isVisible, timeout=1000) + compact_popup.compact = True + qtbot.waitUntil(compact_popup.compact_view.isVisible, timeout=1000) + qtbot.mouseClick(compact_popup.compact_show_popup, Qt.LeftButton) + qtbot.waitUntil(compact_popup.container.isVisible, timeout=1000) + compact_popup._popup_window.close() + qtbot.waitUntil(lambda: not compact_popup.container.isVisible(), timeout=1000)