diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index d002940c..88c3eae4 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -19,6 +19,7 @@ class Widgets(str, enum.Enum): BECFigure = "BECFigure" BECImageWidget = "BECImageWidget" BECMotorMapWidget = "BECMotorMapWidget" + BECProgressBar = "BECProgressBar" BECQueue = "BECQueue" BECStatusBox = "BECStatusBox" BECWaveformWidget = "BECWaveformWidget" @@ -1693,6 +1694,48 @@ class BECPlotBase(RPCBase): """ +class BECProgressBar(RPCBase): + @rpc_call + def set_value(self, value): + """ + Smoothly transition the progress bar to the new value. + """ + + @rpc_call + def set_maximum(self, maximum: float): + """ + Set the maximum value of the progress bar. + """ + + @rpc_call + def set_minimum(self, minimum: float): + """ + None + """ + + @property + @rpc_call + def label_template(self): + """ + The template for the center label. Use $value, $maximum, and $percentage to insert the values. + + Examples: + >>> progressbar.label_template = "$value / $maximum - $percentage %" + >>> progressbar.label_template = "$value / $percentage %" + """ + + @label_template.setter + @rpc_call + def label_template(self): + """ + The template for the center label. Use $value, $maximum, and $percentage to insert the values. + + Examples: + >>> progressbar.label_template = "$value / $maximum - $percentage %" + >>> progressbar.label_template = "$value / $percentage %" + """ + + class BECQueue(RPCBase): @property @rpc_call diff --git a/bec_widgets/widgets/bec_progressbar/__init__.py b/bec_widgets/widgets/bec_progressbar/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/bec_progressbar/bec_progress_bar.pyproject b/bec_widgets/widgets/bec_progressbar/bec_progress_bar.pyproject new file mode 100644 index 00000000..a124c994 --- /dev/null +++ b/bec_widgets/widgets/bec_progressbar/bec_progress_bar.pyproject @@ -0,0 +1 @@ +{'files': ['bec_progressbar.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/bec_progressbar/bec_progress_bar_plugin.py b/bec_widgets/widgets/bec_progressbar/bec_progress_bar_plugin.py new file mode 100644 index 00000000..14968741 --- /dev/null +++ b/bec_widgets/widgets/bec_progressbar/bec_progress_bar_plugin.py @@ -0,0 +1,54 @@ +# Copyright (C) 2022 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause + +from qtpy.QtDesigner import QDesignerCustomWidgetInterface + +from bec_widgets.utils.bec_designer import designer_material_icon +from bec_widgets.widgets.bec_progressbar.bec_progressbar import BECProgressBar + +DOM_XML = """ + + + + +""" + + +class BECProgressBarPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + t = BECProgressBar(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "BEC Utils" + + def icon(self): + return designer_material_icon(BECProgressBar.ICON_NAME) + + def includeFile(self): + return "bec_progress_bar" + + def initialize(self, form_editor): + self._form_editor = form_editor + + def isContainer(self): + return False + + def isInitialized(self): + return self._form_editor is not None + + def name(self): + return "BECProgressBar" + + def toolTip(self): + return "A custom progress bar with smooth transitions and a modern design." + + def whatsThis(self): + return self.toolTip() diff --git a/bec_widgets/widgets/bec_progressbar/bec_progressbar.py b/bec_widgets/widgets/bec_progressbar/bec_progressbar.py new file mode 100644 index 00000000..420f7d43 --- /dev/null +++ b/bec_widgets/widgets/bec_progressbar/bec_progressbar.py @@ -0,0 +1,249 @@ +import sys +from string import Template + +from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer, Slot +from qtpy.QtGui import QColor, QPainter, QPainterPath +from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget + +from bec_widgets.utils.bec_widget import BECWidget + + +class BECProgressBar(BECWidget, QWidget): + """ + A custom progress bar with smooth transitions. The displayed text can be customized using a template. + """ + + USER_ACCESS = [ + "set_value", + "set_maximum", + "set_minimum", + "label_template", + "label_template.setter", + ] + ICON_NAME = "page_control" + + def __init__(self, parent=None, client=None, config=None, gui_id=None): + super().__init__(client=client, config=config, gui_id=gui_id) + QWidget.__init__(self, parent=parent) + + accent_colors = QApplication.instance().theme.accent_colors + + # internal values + self._oversampling_factor = 50 + self._value = 0 + self._target_value = 0 + self._maximum = 100 * self._oversampling_factor + + # User values + self._user_value = 0 + self._user_minimum = 0 + self._user_maximum = 100 + self._label_template = "$value / $maximum - $percentage %" + + # Color settings + self._background_color = QColor(30, 30, 30) + self._progress_color = accent_colors.highlight # QColor(210, 55, 130) + + self._completed_color = accent_colors.success + self._border_color = QColor(50, 50, 50) + + # layout settings + self._value_animation = QPropertyAnimation(self, b"_progressbar_value") + self._value_animation.setDuration(200) + self._value_animation.setEasingCurve(QEasingCurve.Type.OutCubic) + + # label on top of the progress bar + self.center_label = QLabel(self) + self.center_label.setAlignment(Qt.AlignCenter) + self.center_label.setStyleSheet("color: white;") + self.center_label.setMinimumSize(0, 0) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self.center_label) + self.setLayout(layout) + + self.update() + + @Property(str, doc="The template for the center label. Use $value, $maximum, and $percentage.") + def label_template(self): + """ + The template for the center label. Use $value, $maximum, and $percentage to insert the values. + + Examples: + >>> progressbar.label_template = "$value / $maximum - $percentage %" + >>> progressbar.label_template = "$value / $percentage %" + + """ + return self._label_template + + @label_template.setter + def label_template(self, template): + self._label_template = template + self.set_value(self._user_value) + self.update() + + @Property(float, designable=False) + def _progressbar_value(self): + """ + The current value of the progress bar. + """ + return self._value + + @_progressbar_value.setter + def _progressbar_value(self, val): + self._value = val + self.update() + + def _update_template(self): + template = Template(self._label_template) + return template.safe_substitute( + value=self._user_value, + maximum=self._user_maximum, + percentage=int((self.map_value(self._user_value) / self._maximum) * 100), + ) + + @Slot(float) + @Slot(int) + def set_value(self, value): + """Smoothly transition the progress bar to the new value.""" + if value > self._user_maximum: + value = self._user_maximum + elif value < self._user_minimum: + value = self._user_minimum + self._target_value = self.map_value(value) + self._user_value = value + self.center_label.setText(self._update_template()) + self.animate_progress() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + rect = self.rect().adjusted(10, 0, -10, -1) + + # Draw background + painter.setBrush(self._background_color) + painter.setPen(Qt.NoPen) + painter.drawRoundedRect(rect, 10, 10) # Rounded corners + + # Draw border + painter.setBrush(Qt.NoBrush) + painter.setPen(self._border_color) + painter.drawRoundedRect(rect, 10, 10) + + # Determine progress color based on completion + if self._value >= self._maximum: + current_color = self._completed_color + else: + current_color = self._progress_color + + # Set clipping region to preserve the background's rounded corners + progress_rect = rect.adjusted( + 0, 0, int(-rect.width() + (self._value / self._maximum) * rect.width()), 0 + ) + clip_path = QPainterPath() + clip_path.addRoundedRect(QRectF(rect), 10, 10) # Clip to the background's rounded corners + painter.setClipPath(clip_path) + + # Draw progress bar + painter.setBrush(current_color) + painter.drawRect(progress_rect) # Less rounded, no additional rounding + + painter.end() + + def animate_progress(self): + """ + Animate the progress bar from the current value to the target value. + """ + self._value_animation.stop() + self._value_animation.setStartValue(self._value) + self._value_animation.setEndValue(self._target_value) + self._value_animation.start() + + @Property(float) + def maximum(self): + """ + The maximum value of the progress bar. + """ + return self._user_maximum + + @maximum.setter + def maximum(self, maximum: float): + """ + Set the maximum value of the progress bar. + """ + self.set_maximum(maximum) + + @Property(float) + def minimum(self): + """ + The minimum value of the progress bar. + """ + return self._user_minimum + + @minimum.setter + def minimum(self, minimum: float): + self.set_minimum(minimum) + + @Property(float) + def initial_value(self): + """ + The initial value of the progress bar. + """ + return self._user_value + + @initial_value.setter + def initial_value(self, value: float): + self.set_value(value) + + @Slot(float) + def set_maximum(self, maximum: float): + """ + Set the maximum value of the progress bar. + """ + self._user_maximum = maximum + self.set_value(self._user_value) # Update the value to fit the new range + self.update() + + @Slot(float) + def set_minimum(self, minimum: float): + self._user_minimum = minimum + self.set_value(self._user_value) # Update the value to fit the new range + self.update() + + def map_value(self, value: float): + """ + Map the user value to the range [0, 100*self._oversampling_factor] for the progress + """ + return ( + (value - self._user_minimum) / (self._user_maximum - self._user_minimum) * self._maximum + ) + + def sizeHint(self): + return self.minimumSizeHint() + + def minimumSizeHint(self): + return self.size() + + +if __name__ == "__main__": # pragma: no cover + app = QApplication(sys.argv) + + progressBar = BECProgressBar() + progressBar.show() + progressBar.set_minimum(-100) + progressBar.set_maximum(0) + + # Example of setting values + def update_progress(): + value = progressBar._user_value + 2.5 + if value > progressBar._user_maximum: + value = -100 # progressBar._maximum / progressBar._upsampling_factor + progressBar.set_value(value) + + timer = QTimer() + timer.timeout.connect(update_progress) + timer.start(200) # Update every half second + + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/bec_progressbar/register_bec_progress_bar.py b/bec_widgets/widgets/bec_progressbar/register_bec_progress_bar.py new file mode 100644 index 00000000..f60f7bf0 --- /dev/null +++ b/bec_widgets/widgets/bec_progressbar/register_bec_progress_bar.py @@ -0,0 +1,15 @@ +def main(): # pragma: no cover + from qtpy import PYSIDE6 + + if not PYSIDE6: + print("PYSIDE6 is not available in the environment. Cannot patch designer.") + return + from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection + + from bec_widgets.widgets.bec_progressbar.bec_progress_bar_plugin import BECProgressBarPlugin + + QPyDesignerCustomWidgetCollection.addCustomWidget(BECProgressBarPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/tests/unit_tests/test_bec_progressbar.py b/tests/unit_tests/test_bec_progressbar.py new file mode 100644 index 00000000..70edb6b4 --- /dev/null +++ b/tests/unit_tests/test_bec_progressbar.py @@ -0,0 +1,35 @@ +import numpy as np +import pytest + +from bec_widgets.widgets.bec_progressbar.bec_progressbar import BECProgressBar + + +@pytest.fixture +def progressbar(qtbot): + widget = BECProgressBar() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +def test_progressbar(progressbar): + progressbar.update() + + +def test_progressbar_set_value(qtbot, progressbar): + progressbar.set_minimum(0) + progressbar.set_maximum(100) + progressbar.set_value(50) + progressbar.paintEvent(None) + + qtbot.waitUntil( + lambda: np.isclose( + progressbar._value, progressbar._user_value * progressbar._oversampling_factor + ) + ) + + +def test_progressbar_label(progressbar): + progressbar.label_template = "Test: $value" + progressbar.set_value(50) + assert progressbar.center_label.text() == "Test: 50"