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:
223
bec_widgets/qt_utils/compact_popup.py
Normal file
223
bec_widgets/qt_utils/compact_popup.py
Normal 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()
|
66
tests/unit_tests/test_compact_popup_widget.py
Normal file
66
tests/unit_tests/test_compact_popup_widget.py
Normal 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)
|
Reference in New Issue
Block a user