From b039933405e2fbe92bd81bd0748e79e8d443a741 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sun, 27 Oct 2024 14:42:44 +0100 Subject: [PATCH] feat(colormap_button): colormap button with menu to select colormap filtered by the colormap type --- bec_widgets/cli/client.py | 21 ++++++ bec_widgets/utils/colors.py | 21 ++++-- .../widgets/colormap_widget/__init__.py | 0 .../bec_color_map_widget.pyproject | 1 + .../bec_color_map_widget_plugin.py | 54 ++++++++++++++ .../colormap_widget/colormap_widget.py | 73 +++++++++++++++++++ .../register_bec_color_map_widget.py | 17 +++++ .../curve_dialog/curve_dialog.py | 6 +- .../curve_dialog/curve_dialog.ui | 22 +++++- .../buttons_appearance/buttons_appearance.md | 38 ++++++++++ tests/unit_tests/test_colormap_widget.py | 69 ++++++++++++++++++ 11 files changed, 308 insertions(+), 14 deletions(-) create mode 100644 bec_widgets/widgets/colormap_widget/__init__.py create mode 100644 bec_widgets/widgets/colormap_widget/bec_color_map_widget.pyproject create mode 100644 bec_widgets/widgets/colormap_widget/bec_color_map_widget_plugin.py create mode 100644 bec_widgets/widgets/colormap_widget/colormap_widget.py create mode 100644 bec_widgets/widgets/colormap_widget/register_bec_color_map_widget.py create mode 100644 tests/unit_tests/test_colormap_widget.py diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 9388aca7..8b8d3594 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -16,6 +16,7 @@ class Widgets(str, enum.Enum): """ AbortButton = "AbortButton" + BECColorMapWidget = "BECColorMapWidget" BECDock = "BECDock" BECDockArea = "BECDockArea" BECFigure = "BECFigure" @@ -34,6 +35,7 @@ class Widgets(str, enum.Enum): PositionIndicator = "PositionIndicator" PositionerBox = "PositionerBox" PositionerControlLine = "PositionerControlLine" + PositionerGroup = "PositionerGroup" ResetButton = "ResetButton" ResumeButton = "ResumeButton" RingProgressBar = "RingProgressBar" @@ -64,6 +66,15 @@ class AbortButton(RPCBase): """ +class BECColorMapWidget(RPCBase): + @property + @rpc_call + def colormap(self): + """ + Get the current colormap name. + """ + + class BECCurve(RPCBase): @rpc_call def remove(self): @@ -2690,6 +2701,16 @@ class PositionerControlLine(RPCBase): """ +class PositionerGroup(RPCBase): + @rpc_call + def set_positioners(self, device_names: "str"): + """ + Redraw grid with positioners from device_names string + + Device names must be separated by space + """ + + class ResetButton(RPCBase): @property @rpc_call diff --git a/bec_widgets/utils/colors.py b/bec_widgets/utils/colors.py index 8ce8c609..a6d9d365 100644 --- a/bec_widgets/utils/colors.py +++ b/bec_widgets/utils/colors.py @@ -468,7 +468,7 @@ class Colors: return color @staticmethod - def validate_color_map(color_map: str) -> str: + def validate_color_map(color_map: str, return_error: bool = True) -> str | bool: """ Validate the colormap input if it is supported by pyqtgraph. Can be used in any pydantic model as a field validator. If validation fails it prints all available colormaps from pyqtgraph instance. @@ -476,13 +476,20 @@ class Colors: color_map(str): The colormap to be validated. Returns: - str: The validated colormap. + str: The validated colormap, if colormap is valid. + bool: False, if colormap is invalid. + + Raises: + PydanticCustomError: If colormap is invalid. """ available_colormaps = pg.colormap.listMaps() if color_map not in available_colormaps: - raise PydanticCustomError( - "unsupported colormap", - f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.", - {"wrong_value": color_map}, - ) + if return_error: + raise PydanticCustomError( + "unsupported colormap", + f"Colormap '{color_map}' not found in the current installation of pyqtgraph. Choose on the following: {available_colormaps}.", + {"wrong_value": color_map}, + ) + else: + return False return color_map diff --git a/bec_widgets/widgets/colormap_widget/__init__.py b/bec_widgets/widgets/colormap_widget/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/colormap_widget/bec_color_map_widget.pyproject b/bec_widgets/widgets/colormap_widget/bec_color_map_widget.pyproject new file mode 100644 index 00000000..02243eb8 --- /dev/null +++ b/bec_widgets/widgets/colormap_widget/bec_color_map_widget.pyproject @@ -0,0 +1 @@ +{'files': ['colormap_widget.py']} \ No newline at end of file diff --git a/bec_widgets/widgets/colormap_widget/bec_color_map_widget_plugin.py b/bec_widgets/widgets/colormap_widget/bec_color_map_widget_plugin.py new file mode 100644 index 00000000..4b648f3d --- /dev/null +++ b/bec_widgets/widgets/colormap_widget/bec_color_map_widget_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.colormap_widget.colormap_widget import BECColorMapWidget + +DOM_XML = """ + + + + +""" + + +class BECColorMapWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover + def __init__(self): + super().__init__() + self._form_editor = None + + def createWidget(self, parent): + t = BECColorMapWidget(parent) + return t + + def domXml(self): + return DOM_XML + + def group(self): + return "BEC Buttons" + + def icon(self): + return designer_material_icon(BECColorMapWidget.ICON_NAME) + + def includeFile(self): + return "bec_color_map_widget" + + 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 "BECColorMapWidget" + + def toolTip(self): + return "BECColorMapWidget" + + def whatsThis(self): + return self.toolTip() diff --git a/bec_widgets/widgets/colormap_widget/colormap_widget.py b/bec_widgets/widgets/colormap_widget/colormap_widget.py new file mode 100644 index 00000000..e6af4ee1 --- /dev/null +++ b/bec_widgets/widgets/colormap_widget/colormap_widget.py @@ -0,0 +1,73 @@ +from pyqtgraph.widgets.ColorMapButton import ColorMapButton +from qtpy.QtCore import Property, Signal, Slot +from qtpy.QtWidgets import QSizePolicy, QVBoxLayout, QWidget + +from bec_widgets.utils import Colors +from bec_widgets.utils.bec_widget import BECWidget + + +class BECColorMapWidget(BECWidget, QWidget): + colormap_changed_signal = Signal(str) + ICON_NAME = "palette" + USER_ACCESS = ["colormap"] + + def __init__(self, parent=None, cmap: str = "magma"): + super().__init__() + QWidget.__init__(self, parent=parent) + + # Create the ColorMapButton + self.button = ColorMapButton() + + # Set the size policy and minimum width + size_policy = QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Fixed) + self.button.setSizePolicy(size_policy) + self.button.setMinimumWidth(100) + self.button.setMinimumHeight(30) + + # Create the layout + self.layout = QVBoxLayout(self) + self.layout.addWidget(self.button) + self.layout.setSpacing(0) + self.layout.setContentsMargins(0, 0, 0, 0) + + # Set the initial colormap + self.button.setColorMap(cmap) + self._cmap = cmap + + # Connect the signal + self.button.sigColorMapChanged.connect(self.colormap_changed) + + @Property(str) + def colormap(self): + """Get the current colormap name.""" + return self._cmap + + @colormap.setter + def colormap(self, name): + """Set the colormap by name.""" + if self._cmap != name: + if Colors.validate_color_map(name, return_error=False) is False: + return + self.button.setColorMap(name) + self._cmap = name + self.colormap_changed_signal.emit(name) + + @Slot() + def colormap_changed(self): + """ + Emit the colormap changed signal with the current colormap selected in the button. + """ + cmap = self.button.colorMap().name + self._cmap = cmap + self.colormap_changed_signal.emit(cmap) + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + window = BECColorMapWidget() + window.show() + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/colormap_widget/register_bec_color_map_widget.py b/bec_widgets/widgets/colormap_widget/register_bec_color_map_widget.py new file mode 100644 index 00000000..fbf05edf --- /dev/null +++ b/bec_widgets/widgets/colormap_widget/register_bec_color_map_widget.py @@ -0,0 +1,17 @@ +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.colormap_widget.bec_color_map_widget_plugin import ( + BECColorMapWidgetPlugin, + ) + + QPyDesignerCustomWidgetCollection.addCustomWidget(BECColorMapWidgetPlugin()) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/bec_widgets/widgets/waveform/waveform_popups/curve_dialog/curve_dialog.py b/bec_widgets/widgets/waveform/waveform_popups/curve_dialog/curve_dialog.py index c7f3ea80..4b924bb3 100644 --- a/bec_widgets/widgets/waveform/waveform_popups/curve_dialog/curve_dialog.py +++ b/bec_widgets/widgets/waveform/waveform_popups/curve_dialog/curve_dialog.py @@ -53,7 +53,7 @@ class CurveSettings(SettingWidget): x_entry = self.target_widget.waveform._x_axis_mode["entry"] self._enable_ui_elements(x_name, x_entry) cm = self.target_widget.config.color_palette - self.ui.color_map_selector_scan.combo.setCurrentText(cm) + self.ui.color_map_selector_scan.colormap = cm # Scan Curve Table for source in ["scan_segment", "async"]: @@ -115,10 +115,10 @@ class CurveSettings(SettingWidget): @Slot() def change_colormap(self, target: Literal["scan", "dap"]): if target == "scan": - cm = self.ui.color_map_selector_scan.combo.currentText() + cm = self.ui.color_map_selector_scan.colormap table = self.ui.scan_table if target == "dap": - cm = self.ui.color_map_selector_dap.combo.currentText() + cm = self.ui.color_map_selector_dap.colormap table = self.ui.dap_table rows = table.rowCount() colors = Colors.golden_angle_color(colormap=cm, num=max(10, rows + 1), format="HEX") diff --git a/bec_widgets/widgets/waveform/waveform_popups/curve_dialog/curve_dialog.ui b/bec_widgets/widgets/waveform/waveform_popups/curve_dialog/curve_dialog.ui index 62a36f47..1f087e90 100644 --- a/bec_widgets/widgets/waveform/waveform_popups/curve_dialog/curve_dialog.ui +++ b/bec_widgets/widgets/waveform/waveform_popups/curve_dialog/curve_dialog.ui @@ -231,7 +231,14 @@ - + + + + 0 + 0 + + + @@ -330,7 +337,14 @@ - + + + + 0 + 0 + + + @@ -348,9 +362,9 @@
device_line_edit
- ColormapSelector + BECColorMapWidget QWidget -
colormap_selector
+
bec_color_map_widget
diff --git a/docs/user/widgets/buttons_appearance/buttons_appearance.md b/docs/user/widgets/buttons_appearance/buttons_appearance.md index cba5c2e9..31af3c8a 100644 --- a/docs/user/widgets/buttons_appearance/buttons_appearance.md +++ b/docs/user/widgets/buttons_appearance/buttons_appearance.md @@ -39,6 +39,17 @@ The `Colormap Selector` is a specialized combobox that allows users to select a **Key Features:** - **Colormap Selection**: Provides a dropdown to select from all available colormaps in `pyqtgraph`. - **Visual Preview**: Displays a small preview of the colormap next to its name, enhancing usability. + +## Colormap Button + +The `Colormap Button` is a custom widget that displays the current colormap and, upon clicking, shows a nested menu for selecting a different colormap. It integrates the `ColorMapMenu` from `pyqtgraph`, providing an intuitive and interactive way for users to choose colormaps within the GUI. + +**Key Features:** +- **Current Colormap Display**: Shows the name and a gradient icon of the current colormap directly on the button. +- **Nested Menu Selection**: Offers a nested menu with categorized colormaps, making it easy to find and select the desired colormap. +- **Signal Emission**: Emits a signal when the colormap changes, providing the new colormap name as a string. +- **Qt Designer Integration**: Exposes properties and signals to be used within Qt Designer, allowing for customization within the designer interface. +- **Resizable and Styled**: Features adjustable size policies and styles to match the look and feel of standard `QPushButton` widgets, including rounded edges. ````` ````{tab} Examples @@ -104,6 +115,33 @@ class MyGui(QWidget): my_gui = MyGui() my_gui.show() ``` + +## Example 4 - Adding a Colormap Button + +```python +from qtpy.QtWidgets import QWidget, QVBoxLayout +from bec_widgets.widgets.buttons import ColormapButton + +class MyGui(QWidget): + def __init__(self): + super().__init__() + self.setLayout(QVBoxLayout(self)) + + # Create and add the ColormapButton to the layout + self.colormap_button = ColormapButton() + self.layout().addWidget(self.colormap_button) + + # Connect the signal to handle colormap changes + self.colormap_button.colormap_changed_signal.connect(self.on_colormap_changed) + + def on_colormap_changed(self, colormap_name): + print(f"Selected colormap: {colormap_name}") + +# Example of how this custom GUI might be used: +my_gui = MyGui() +my_gui.show() +``` + ```` ````{tab} API diff --git a/tests/unit_tests/test_colormap_widget.py b/tests/unit_tests/test_colormap_widget.py new file mode 100644 index 00000000..553cecd2 --- /dev/null +++ b/tests/unit_tests/test_colormap_widget.py @@ -0,0 +1,69 @@ +import pytest +from pyqtgraph.widgets.ColorMapButton import ColorMapButton + +from bec_widgets.widgets.colormap_widget.colormap_widget import BECColorMapWidget + + +@pytest.fixture +def color_map_widget(qtbot): + widget = BECColorMapWidget() + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + +def test_color_map_widget_init(color_map_widget): + """Test that the widget initializes correctly.""" + assert color_map_widget is not None + assert isinstance(color_map_widget, BECColorMapWidget) + assert color_map_widget.colormap == "magma" + assert isinstance(color_map_widget.button, ColorMapButton) + # Check that the button has the correct initial colormap + assert color_map_widget.button.colorMap().name == "magma" + + +def test_color_map_widget_set_valid_colormap(color_map_widget): + """ + Test setting a valid colormap. + """ + new_cmap = "viridis" + color_map_widget.colormap = new_cmap + assert color_map_widget.colormap == new_cmap + assert color_map_widget.button.colorMap().name == new_cmap + + +def test_color_map_widget_set_invalid_colormap(color_map_widget): + """Test setting an invalid colormap.""" + invalid_cmap = "invalid_colormap_name" + old_cmap = color_map_widget.colormap + color_map_widget.colormap = invalid_cmap + # Since invalid, the colormap should not change + assert color_map_widget.colormap == old_cmap + assert color_map_widget.button.colorMap().name == old_cmap + + +def test_color_map_widget_signal_emitted(color_map_widget, qtbot): + """Test that the signal is emitted when the colormap changes.""" + new_cmap = "plasma" + with qtbot.waitSignal(color_map_widget.colormap_changed_signal, timeout=1000) as blocker: + color_map_widget.colormap = new_cmap + assert blocker.signal_triggered + assert blocker.args == [new_cmap] + assert color_map_widget.colormap == new_cmap + + +def test_color_map_widget_signal_not_emitted_for_invalid_colormap(color_map_widget, qtbot): + """Test that the signal is not emitted when an invalid colormap is set.""" + invalid_cmap = "invalid_colormap_name" + with qtbot.assertNotEmitted(color_map_widget.colormap_changed_signal): + color_map_widget.colormap = invalid_cmap + # The colormap should remain unchanged + assert color_map_widget.colormap == "magma" + + +def test_color_map_widget_resize(color_map_widget): + """Test that the widget resizes properly.""" + width, height = 200, 50 + color_map_widget.resize(width, height) + assert color_map_widget.width() == width + assert color_map_widget.height() == height