0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-13 19:21:50 +02:00

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
This commit is contained in:
2024-10-03 16:10:28 +02:00
parent 908dbc1760
commit 49268e3829
2 changed files with 289 additions and 0 deletions

View File

@ -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()

View File

@ -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)