From d15b22250fbceb708d89872c0380693e04acb107 Mon Sep 17 00:00:00 2001 From: wakonig_k Date: Sat, 14 Sep 2024 18:33:00 +0200 Subject: [PATCH] feat(position_indicator): improved design and added more customization options --- .../position_indicator/position_indicator.py | 283 ++++++++++++++++-- .../widgets/positioner_box/positioner_box.py | 2 +- tests/unit_tests/test_position_indicator.py | 42 +++ 3 files changed, 293 insertions(+), 34 deletions(-) create mode 100644 tests/unit_tests/test_position_indicator.py diff --git a/bec_widgets/widgets/position_indicator/position_indicator.py b/bec_widgets/widgets/position_indicator/position_indicator.py index 40f74165..a5377a93 100644 --- a/bec_widgets/widgets/position_indicator/position_indicator.py +++ b/bec_widgets/widgets/position_indicator/position_indicator.py @@ -1,67 +1,284 @@ -from qtpy.QtCore import Qt, Slot -from qtpy.QtGui import QPainter, QPen +import numpy as np +from qtpy.QtCore import Property, QSize, Qt, Slot +from qtpy.QtGui import QBrush, QColor, QPainter, QPainterPath, QPen from qtpy.QtWidgets import QWidget +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import get_accent_colors, get_theme_palette -class PositionIndicator(QWidget): + +class PositionIndicator(BECWidget, QWidget): ICON_NAME = "horizontal_distribute" - def __init__(self, parent=None): - super().__init__(parent) - self.position = 0.5 + 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) + self.position = 50 self.min_value = 0 self.max_value = 100 self.scaling_factor = 0.5 - self.setMinimumHeight(10) + self.is_vertical = False + self._current_indicator_position = 0 + self._draw_position = 0 + self._rounded_corners = 10 + self._indicator_width = 2 + self._indicator_color = get_accent_colors().success + self._background_color = get_theme_palette().mid().color() + self._use_color_palette = True - def set_range(self, min_value, max_value): + def set_range(self, min_value: float, max_value: float): + """ + Set the range of the position indicator + + Args: + min_value(float): Minimum value of the range + max_value(float): Maximum value of the range + """ + self.minimum = min_value + self.maximum = max_value + + @Property(float) + def minimum(self): + """ + Property to get the minimum value of the position indicator + """ + return self.min_value + + @minimum.setter + def minimum(self, min_value: float): + """ + Setter for the minimum property + + Args: + min_value: The minimum value of the position indicator + """ self.min_value = min_value - self.max_value = max_value + self.update() + @Property(float) + def maximum(self): + """ + Property to get the maximum value of the position indicator + """ + return self.max_value + + @maximum.setter + def maximum(self, max_value: float): + """ + Setter for the maximum property + + Args: + max_value: The maximum value of the position indicator + """ + self.max_value = max_value + self.update() + + @Property(bool) + def vertical(self): + """ + Property to determine the orientation of the position indicator + """ + return self.is_vertical + + @vertical.setter + def vertical(self, is_vertical: bool): + """ + Setter for the vertical property + + Args: + is_vertical: True if the indicator should be vertical, False if horizontal + """ + + self.is_vertical = is_vertical + self.update() + + @Property(float) + def value(self): + """ + Property to get the current value of the position indicator + """ + return self.position + + @value.setter + def value(self, position: float): + """ + Setter for the value property + + Args: + position: The new position of the indicator + """ + self.set_value(position) + + @Property(int) + def indicator_width(self): + """ + Property to get the width of the indicator + """ + return self._indicator_width + + @indicator_width.setter + def indicator_width(self, width: int): + """ + Setter for the indicator width property + + Args: + width: The new width of the indicator + """ + self._indicator_width = width + self.update() + + @Property(int) + def rounded_corners(self): + """ + Property to get the rounded corners of the position indicator + """ + return self._rounded_corners + + @rounded_corners.setter + def rounded_corners(self, value: int): + """ + Setter for the rounded corners property + + Args: + value: The new value for the rounded corners + """ + self._rounded_corners = value + self.update() + + @Property(QColor) + def indicator_color(self): + """ + Property to get the color of the indicator + """ + return self._indicator_color + + @indicator_color.setter + def indicator_color(self, color: QColor): + """ + Setter for the indicator color property + + Args: + color: The new color for the indicator + """ + self._indicator_color = color + self.update() + + @Property(QColor) + def background_color(self): + """ + Property to get the background color of the position indicator + """ + return self._background_color + + @background_color.setter + def background_color(self, color: QColor): + """ + Setter for the background color property + + Args: + color: The new background color + """ + self._background_color = color + self.update() + + @Property(bool) + def use_color_palette(self): + """ + Property to determine if the indicator should use the color palette or the custom color. + """ + return self._use_color_palette + + @use_color_palette.setter + def use_color_palette(self, use_palette: bool): + """ + Setter for the use color palette property + + Args: + use_palette: True if the indicator should use the color palette, False if custom color + """ + self._use_color_palette = use_palette + self.update() + + # @Property(float) + @Slot(int) @Slot(float) - def on_position_update(self, position: float): + def set_value(self, position: float): self.position = position self.update() + def _get_indicator_color(self): + if self._use_color_palette: + return get_accent_colors().success + return self._indicator_color + + def _get_background_brush(self): + if self._use_color_palette: + return get_theme_palette().mid() + return QBrush(self._background_color) + def paintEvent(self, event): painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) - width = self.width() height = self.height() - # Draw horizontal line - painter.setPen(Qt.black) - painter.drawLine(0, height // 2, width, height // 2) + # Set up the brush for the background + painter.setBrush(self._get_background_brush()) - # Draw shorter vertical line at the current position - x_pos = int(self.position * width) - painter.setPen(QPen(Qt.red, 2)) - short_line_height = int(height * self.scaling_factor) - painter.drawLine( - x_pos, - (height // 2) - (short_line_height // 2), - x_pos, - (height // 2) + (short_line_height // 2), + # Create a QPainterPath with a rounded rectangle for clipping + path = QPainterPath() + path.addRoundedRect(0, 0, width, height, self._rounded_corners, self._rounded_corners) + + # Set clipping to the rounded rectangle + painter.setClipPath(path) + + # Draw the rounded rectangle background first + painter.setPen(Qt.NoPen) + painter.drawRoundedRect(0, 0, width, height, self._rounded_corners, self._rounded_corners) + + # get the position scaled to the defined min and max values + self._current_indicator_position = position = np.interp( + self.position, [self.min_value, self.max_value], [0, 100] ) - # Draw thicker vertical lines at the ends - end_line_pen = QPen(Qt.blue, 5) - painter.setPen(end_line_pen) - painter.drawLine(0, 0, 0, height) - painter.drawLine(width - 1, 0, width - 1, height) + if self.is_vertical: + # If vertical, rotate the coordinate system by -90 degrees + painter.translate(width // 2, height // 2) # Move origin to center + painter.rotate(-90) # Rotate by -90 degrees for vertical drawing + painter.translate(-height // 2, -width // 2) # Restore the origin for drawing + + # Switch width and height for the vertical orientation + width, height = height, width + + # Draw the moving vertical indicator, respecting the clip path + self._draw_position = x_pos = round( + position * width / 100 + ) # Position for the vertical line + + indicator_pen = QPen(self._get_indicator_color(), self._indicator_width) + painter.setPen(indicator_pen) + painter.drawLine(x_pos, 0, x_pos, height) + + painter.end() + + def minimumSizeHint(self): + # Set the smallest possible size + return QSize(10, 10) -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover + from bec_qthemes import setup_theme from qtpy.QtWidgets import QApplication, QSlider, QVBoxLayout app = QApplication([]) - + setup_theme("dark") + # Create position indicator and slider position_indicator = PositionIndicator() + # position_indicator.set_range(0, 1) slider = QSlider(Qt.Horizontal) - slider.valueChanged.connect(lambda value: position_indicator.on_position_update(value / 100)) - + slider.valueChanged.connect(lambda value: position_indicator.set_value(value)) + position_indicator.is_vertical = False + # position_indicator.set_value(100) layout = QVBoxLayout() layout.addWidget(position_indicator) layout.addWidget(slider) diff --git a/bec_widgets/widgets/positioner_box/positioner_box.py b/bec_widgets/widgets/positioner_box/positioner_box.py index 0b46d73a..9bee1005 100644 --- a/bec_widgets/widgets/positioner_box/positioner_box.py +++ b/bec_widgets/widgets/positioner_box/positioner_box.py @@ -249,7 +249,7 @@ class PositionerBox(BECWidget, QWidget): self.update_limits(limits) if limits is not None and readback_val is not None and limits[0] != limits[1]: pos = (readback_val - limits[0]) / (limits[1] - limits[0]) - self.ui.position_indicator.on_position_update(pos) + self.ui.position_indicator.set_value(pos) def update_limits(self, limits: tuple): """Update limits diff --git a/tests/unit_tests/test_position_indicator.py b/tests/unit_tests/test_position_indicator.py new file mode 100644 index 00000000..4b7db10c --- /dev/null +++ b/tests/unit_tests/test_position_indicator.py @@ -0,0 +1,42 @@ +import pytest + +from bec_widgets.widgets.position_indicator.position_indicator import PositionIndicator + + +@pytest.fixture +def position_indicator(qtbot): + """Fixture for PositionIndicator widget""" + pi = PositionIndicator() + qtbot.addWidget(pi) + qtbot.waitExposed(pi) + return pi + + +def test_position_indicator_set_range(position_indicator): + """ + Test set_range method of PositionIndicator + """ + position_indicator.set_range(0, 20) + assert position_indicator.minimum == 0 + assert position_indicator.maximum == 20 + + +def test_position_indicator_set_value(position_indicator): + """ + Test set_value method of PositionIndicator and the correct mapping of the value + within the paintEvent method + """ + # pylint: disable=protected-access + position_indicator.set_value(50) + assert position_indicator.position == 50 + + position_indicator.paintEvent(None) + assert position_indicator._current_indicator_position == 50 + + position_indicator.set_value(100) + position_indicator.paintEvent(None) + assert position_indicator._draw_position == position_indicator.width() + + position_indicator.vertical = True + position_indicator.paintEvent(None) + assert position_indicator._draw_position == position_indicator.height()