diff --git a/bec_widgets/utils/colors.py b/bec_widgets/utils/colors.py index 653fe56b..699b06e8 100644 --- a/bec_widgets/utils/colors.py +++ b/bec_widgets/utils/colors.py @@ -2,7 +2,7 @@ from __future__ import annotations import re from functools import lru_cache -from typing import Literal +from typing import Any, Literal import numpy as np import pyqtgraph as pg @@ -21,8 +21,7 @@ logger = bec_logger.logger def get_theme_name(): if QApplication.instance() is None or not hasattr(QApplication.instance(), "theme"): return "dark" - else: - return QApplication.instance().theme.theme + return QApplication.instance().theme.theme def get_theme_palette(): @@ -58,6 +57,26 @@ def apply_theme(theme: Literal["dark", "light"]): process_all_deferred_deletes(QApplication.instance()) +def theme_color(theme: Any | None, key: str, fallback: QColor | str) -> QColor: + """ + Return a QColor from a BEC theme with a robust fallback. + """ + fallback_color = fallback if isinstance(fallback, QColor) else QColor(str(fallback)) + if theme is None or not hasattr(theme, "color"): + return fallback_color + color = theme.color(key, fallback_color.name()) + return color if isinstance(color, QColor) else QColor(str(color)) + + +def rgba(color: QColor | str, alpha: int) -> str: + """ + Return a QSS-compatible rgba string with alpha clamped to the 0-255 range. + """ + qcolor = color if isinstance(color, QColor) else QColor(str(color)) + alpha = max(0, min(255, alpha)) + return f"rgba({qcolor.red()}, {qcolor.green()}, {qcolor.blue()}, {alpha})" + + class Colors: @staticmethod def list_available_colormaps() -> list[str]: @@ -150,25 +169,6 @@ class Colors: return ge.colorMap() - @staticmethod - def golden_ratio(num: int) -> list: - """Calculate the golden ratio for a given number of angles. - - Args: - num (int): Number of angles - - Returns: - list: List of angles calculated using the golden ratio. - """ - phi = 2 * np.pi * ((1 + np.sqrt(5)) / 2) - angles = [] - for ii in range(num): - x = np.cos(ii * phi) - y = np.sin(ii * phi) - angle = np.arctan2(y, x) - angles.append(angle) - return angles - @staticmethod def set_theme_offset(theme: Literal["light", "dark"] | None = None, offset=0.2) -> tuple: """ @@ -239,20 +239,7 @@ class Colors: else: positions = np.linspace(min_pos, max_pos, num) - # Sample colors from the colormap at the calculated positions - colors = cmap.map(positions, mode="float") - color_list = [] - - for color in colors: - if format.upper() == "HEX": - color_list.append(QColor.fromRgbF(*color).name()) - elif format.upper() == "RGB": - color_list.append(tuple((np.array(color) * 255).astype(int))) - elif format.upper() == "QCOLOR": - color_list.append(QColor.fromRgbF(*color)) - else: - raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.") - return color_list + return Colors._format_mapped_colors(cmap.map(positions, mode="float"), format) @staticmethod def golden_angle_color( @@ -288,20 +275,19 @@ class Colors: positions = np.mod(np.arange(num) * golden_angle_conjugate, 1) positions = min_pos + positions * (max_pos - min_pos) - # Sample colors from the colormap at the calculated positions - colors = cmap.map(positions, mode="float") - color_list = [] + return Colors._format_mapped_colors(cmap.map(positions, mode="float"), format) - for color in colors: - if format.upper() == "HEX": - color_list.append(QColor.fromRgbF(*color).name()) - elif format.upper() == "RGB": - color_list.append(tuple((np.array(color) * 255).astype(int))) - elif format.upper() == "QCOLOR": - color_list.append(QColor.fromRgbF(*color)) - else: - raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.") - return color_list + @staticmethod + def _format_mapped_colors(colors: np.ndarray, format: Literal["QColor", "HEX", "RGB"]) -> list: + color_format = format.upper() + if color_format not in {"QCOLOR", "HEX", "RGB"}: + raise ValueError("Unsupported format. Please choose 'RGB', 'HEX', or 'QColor'.") + + if color_format == "QCOLOR": + return [QColor.fromRgbF(*color) for color in colors] + if color_format == "HEX": + return [QColor.fromRgbF(*color).name() for color in colors] + return [tuple((np.array(color) * 255).astype(int)) for color in colors] @staticmethod def hex_to_rgba(hex_color: str, alpha=255) -> tuple: @@ -325,22 +311,6 @@ class Colors: raise ValueError("HEX color must be 6 or 8 characters long.") return (r, g, b, alpha) - @staticmethod - def rgba_to_hex(r: int, g: int, b: int, a: int = 255) -> str: - """ - Convert RGBA color to HEX. - - Args: - r(int): Red value (0-255). - g(int): Green value (0-255). - b(int): Blue value (0-255). - a(int): Alpha value (0-255). Default is 255 (opaque). - - Returns: - hec_color(str): HEX color string. - """ - return "#{:02X}{:02X}{:02X}{:02X}".format(r, g, b, a) - @staticmethod def validate_color(color: tuple | str) -> tuple | str: """ diff --git a/tests/unit_tests/test_color_utils.py b/tests/unit_tests/test_color_utils.py index fc68acb6..4547aa70 100644 --- a/tests/unit_tests/test_color_utils.py +++ b/tests/unit_tests/test_color_utils.py @@ -2,11 +2,11 @@ import pyqtgraph as pg import pytest from pydantic import ValidationError from qtpy.QtGui import QColor -from qtpy.QtWidgets import QVBoxLayout, QWidget +from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.colors import Colors, apply_theme +from bec_widgets.utils.colors import Colors, apply_theme, get_theme_name, rgba, theme_color from bec_widgets.widgets.plots.waveform.curve import CurveConfig from tests.unit_tests.client_mocks import mocked_client from tests.unit_tests.conftest import create_widget @@ -76,10 +76,27 @@ def test_hex_to_rgba(): Colors.hex_to_rgba("#FF573") -def test_rgba_to_hex(): - assert Colors.rgba_to_hex(255, 87, 51, 255) == "#FF5733FF" - assert Colors.rgba_to_hex(255, 87, 51, 128) == "#FF573380" - assert Colors.rgba_to_hex(255, 87, 51) == "#FF5733FF" +def test_get_theme_name_uses_application_theme(): + app = QApplication.instance() + assert app.theme.theme == "light" + assert get_theme_name() == "light" + + +def test_theme_color_uses_theme_color_method(): + app = QApplication.instance() + fallback = QColor("#ffffff") + expected = app.theme.color("FG", fallback.name()) + expected = expected if isinstance(expected, QColor) else QColor(str(expected)) + + assert theme_color(app.theme, "FG", fallback).name() == expected.name() + + +def test_theme_color_returns_fallback_without_theme_color_method(): + assert theme_color("light", "FG", QColor("#ffffff")).name() == "#ffffff" + + +def test_qss_rgba_and_blend_helpers(): + assert rgba(QColor("#010203"), 300) == "rgba(1, 2, 3, 255)" def test_canonical_colormap_name_case_insensitive():