diff --git a/bec_widgets/utils/colors.py b/bec_widgets/utils/colors.py index 653fe56b..614d6a40 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,41 @@ 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})" + + +def blend_colors(base: QColor | str, overlay: QColor | str, overlay_alpha: float) -> QColor: + """ + Blend ``overlay`` over ``base`` and return the resulting QColor. + """ + base_color = base if isinstance(base, QColor) else QColor(str(base)) + overlay_color = overlay if isinstance(overlay, QColor) else QColor(str(overlay)) + overlay_alpha = max(0.0, min(1.0, overlay_alpha)) + base_alpha = 1.0 - overlay_alpha + return QColor( + round(base_color.red() * base_alpha + overlay_color.red() * overlay_alpha), + round(base_color.green() * base_alpha + overlay_color.green() * overlay_alpha), + round(base_color.blue() * base_alpha + overlay_color.blue() * overlay_alpha), + ) + + class Colors: @staticmethod def list_available_colormaps() -> list[str]: diff --git a/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_banner.py b/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_banner.py index e48d2f04..385ecc0e 100644 --- a/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_banner.py +++ b/bec_widgets/widgets/containers/main_window/addons/notification_center/notification_banner.py @@ -29,7 +29,7 @@ from qtpy.QtWidgets import QApplication, QFrame, QMainWindow, QScrollArea, QWidg from bec_widgets import SafeProperty, SafeSlot from bec_widgets.utils.bec_connector import BECConnector -from bec_widgets.utils.colors import apply_theme +from bec_widgets.utils.colors import apply_theme, get_theme_name from bec_widgets.utils.widget_io import WidgetIO @@ -367,11 +367,9 @@ class NotificationToast(QFrame): Args: theme(str | None): "light" or "dark". If None, auto-detects from QApplication. """ - # determine effective theme - if theme is None: - app = QApplication.instance() - theme = getattr(getattr(app, "theme", None), "theme", "dark") - theme = theme.lower() + theme = str(theme or get_theme_name()).lower() + if theme not in {"light", "dark"}: + theme = "dark" self._theme = theme palette = DARK_PALETTE if theme == "dark" else LIGHT_PALETTE diff --git a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py index 01dba742..22986989 100644 --- a/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py +++ b/bec_widgets/widgets/services/beamline_states/beamline_state_pill.py @@ -30,6 +30,7 @@ from qtpy.QtWidgets import ( from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import blend_colors, get_theme_name, rgba, theme_color from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.toolbars.actions import MaterialIconAction from bec_widgets.utils.toolbars.bundles import ToolbarBundle @@ -223,7 +224,17 @@ class BeamlineStatePill(BECWidget, QWidget): self._high_limit.valueChanged.connect(self._on_numeric_settings_changed) self._tolerance.valueChanged.connect(self._on_numeric_settings_changed) - for field in self._settings_input_fields(): + self._settings_fields = ( + self._state_type_value, + self._name_value, + self._title_edit, + self._device_edit, + self._signal_edit, + self._low_limit.parentWidget(), + self._high_limit.parentWidget(), + self._tolerance, + ) + for field in self._settings_fields: field.setMinimumWidth(self._SETTINGS_FIELD_WIDTH) field.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) @@ -396,10 +407,18 @@ class BeamlineStatePill(BECWidget, QWidget): active_card = self._expanded border = colors["border"] if self._idle_card_background else "transparent" background = colors["background"] if self._idle_card_background else "transparent" + card_gradient = ( + "qlineargradient(" + "x1:0, y1:0, x2:1, y2:0, " + f"stop:0 {colors['gradient_accent']}, " + f"stop:{colors['gradient_stop']} {colors['card_background']}, " + f"stop:1 {colors['card_background']}" + ")" + ) if active_card: - background = self._card_gradient(colors) + background = card_gradient border = colors["card_border"] - hover_background = self._card_gradient(colors) + hover_background = card_gradient self._shadow.setColor(QColor(colors["shadow"])) self._shadow.setBlurRadius(int(colors["shadow_blur"])) self._shadow.setOffset(0, int(colors["shadow_y_offset"])) @@ -503,7 +522,10 @@ class BeamlineStatePill(BECWidget, QWidget): show_limits = self._show_limit_settings() for widget in self._limit_widgets: - self._set_form_row_visible(widget, show_limits) + widget.setVisible(show_limits) + label = self._settings_form.labelForField(widget) + if label is not None: + label.setVisible(show_limits) if not show_limits: return @@ -550,7 +572,14 @@ class BeamlineStatePill(BECWidget, QWidget): """Mark the current editor values as saved.""" if parameters is None: parameters = self._current_settings_values() - self._update_state_config_parameters(parameters) + if self._state_config: + state_parameters = self._state_config.get("parameters") + if isinstance(state_parameters, dict): + state_parameters.update(parameters) + else: + self._state_config.update(parameters) + if "title" in parameters: + self._state_config["title"] = parameters["title"] self._mark_settings_clean_from_current() def _mark_settings_clean_from_current(self) -> None: @@ -582,29 +611,13 @@ class BeamlineStatePill(BECWidget, QWidget): ) return values - def _update_state_config_parameters(self, parameters: dict[str, Any]) -> None: - if not self._state_config: - return - state_parameters = self._state_config.get("parameters") - if isinstance(state_parameters, dict): - state_parameters.update(parameters) - else: - self._state_config.update(parameters) - for key in ("title",): - if key in parameters: - self._state_config[key] = parameters[key] - @SafeSlot() def _revert_settings(self) -> None: - self._apply_settings_values(self._settings_baseline) - self._update_settings_dirty_state() - - def _apply_settings_values(self, values: dict[str, Any]) -> None: self._populating_settings = True try: - self._title_edit.setText(str(values.get("title") or "")) - device = str(values.get("device") or "") - signal = str(values.get("signal") or "") + self._title_edit.setText(str(self._settings_baseline.get("title") or "")) + device = str(self._settings_baseline.get("device") or "") + signal = str(self._settings_baseline.get("signal") or "") self._set_settings_device(device) self._set_settings_signal(signal) @@ -612,15 +625,18 @@ class BeamlineStatePill(BECWidget, QWidget): return self._set_optional_limit( - self._low_limit_enabled, self._low_limit, values.get("low_limit") + self._low_limit_enabled, self._low_limit, self._settings_baseline.get("low_limit") ) self._set_optional_limit( - self._high_limit_enabled, self._high_limit, values.get("high_limit") + self._high_limit_enabled, + self._high_limit, + self._settings_baseline.get("high_limit"), ) - tolerance = values.get("tolerance") + tolerance = self._settings_baseline.get("tolerance") self._tolerance.setValue(float(tolerance) if tolerance is not None else 0.1) finally: self._populating_settings = False + self._update_settings_dirty_state() @SafeSlot(str) def _on_text_settings_changed(self, _value: str) -> None: @@ -646,27 +662,25 @@ class BeamlineStatePill(BECWidget, QWidget): current_values = self._current_settings_values() fields = set(current_values) | set(self._settings_baseline) - self._settings_dirty_fields = { - field - for field in fields - if not self._settings_values_equal( - current_values.get(field), self._settings_baseline.get(field) - ) - } + dirty_fields = set() + for field in fields: + current = current_values.get(field) + baseline = self._settings_baseline.get(field) + if current is None or baseline is None: + changed = current is not None or baseline is not None + elif isinstance(current, float) or isinstance(baseline, float): + changed = abs(float(current) - float(baseline)) >= 1e-9 + else: + changed = current != baseline + if changed: + dirty_fields.add(field) + self._settings_dirty_fields = dirty_fields has_changes = bool(self._settings_dirty_fields) self._update_button.setEnabled(has_changes) self._revert_button.setEnabled(has_changes) self._apply_dirty_field_highlights() - @staticmethod - def _settings_values_equal(left: Any, right: Any) -> bool: - if left is None or right is None: - return left is None and right is None - if isinstance(left, float) or isinstance(right, float): - return abs(float(left) - float(right)) < 1e-9 - return left == right - def _apply_dirty_field_highlights(self) -> None: field_widgets = { "title": self._title_edit, @@ -677,16 +691,13 @@ class BeamlineStatePill(BECWidget, QWidget): "tolerance": self._tolerance, } for field, widget in field_widgets.items(): - self._set_dirty_property(widget, field in self._settings_dirty_fields) - - @staticmethod - def _set_dirty_property(widget: QWidget | None, dirty: bool) -> None: - if widget is None or widget.property("beamlineStateDirty") == dirty: - return - widget.setProperty("beamlineStateDirty", dirty) - widget.style().unpolish(widget) - widget.style().polish(widget) - widget.update() + dirty = field in self._settings_dirty_fields + if widget is None or widget.property("beamlineStateDirty") == dirty: + continue + widget.setProperty("beamlineStateDirty", dirty) + widget.style().unpolish(widget) + widget.style().polish(widget) + widget.update() @SafeSlot() def _emit_update_requested(self) -> None: @@ -783,33 +794,15 @@ class BeamlineStatePill(BECWidget, QWidget): if enabled: spin_box.setValue(float(value)) - def _settings_input_fields(self) -> tuple[QWidget, ...]: - return ( - self._state_type_value, - self._name_value, - self._title_edit, - self._device_edit, - self._signal_edit, - self._low_limit.parentWidget(), - self._high_limit.parentWidget(), - self._tolerance, - ) - - def _set_form_row_visible(self, widget: QWidget, visible: bool) -> None: - widget.setVisible(visible) - label = self._settings_form.labelForField(widget) - if label is not None: - label.setVisible(visible) - - @classmethod - def _state_colors(cls, status: str) -> dict[str, str]: + @staticmethod + def _state_colors(status: str) -> dict[str, str]: app = QApplication.instance() palette = app.palette() if app is not None else QPalette() theme = getattr(app, "theme", None) if app is not None else None - light_theme = cls._is_light_theme(theme, palette) + light_theme = get_theme_name() == "dark" - foreground = cls._theme_color(theme, "FG", palette.text().color()) - on_primary = cls._theme_color(theme, "ON_PRIMARY", QColor("#ffffff")) + foreground = theme_color(theme, "FG", palette.text().color()) + on_primary = theme_color(theme, "ON_PRIMARY", QColor("#ffffff")) accents = getattr(theme, "accent_colors", None) warning = getattr(accents, "warning", QColor("#EAC435")) @@ -817,44 +810,44 @@ class BeamlineStatePill(BECWidget, QWidget): "valid": getattr(accents, "success", QColor("#2CA58D")), "invalid": getattr(accents, "emergency", QColor("#CC181E")), "warning": warning, - "unknown": cls._theme_color(theme, "ACCENT_DEFAULT", QColor("#7a7a7a")), + "unknown": theme_color(theme, "ACCENT_DEFAULT", QColor("#7a7a7a")), }.get(status, QColor("#7a7a7a")) if not light_theme: - card_bg = cls._theme_color(theme, "CARD_BG", palette.window().color()) - border = cls._theme_color(theme, "BORDER", palette.mid().color()) + card_bg = theme_color(theme, "CARD_BG", palette.window().color()) + border = theme_color(theme, "BORDER", palette.mid().color()) return { "accent": accent.name(), "on_accent": on_primary.name(), "card_background": card_bg.name(), - "card_border": cls._blend(border, accent, 0.45).name(), - "gradient_accent": cls._rgba(accent, 62), + "card_border": blend_colors(border, accent, 0.45).name(), + "gradient_accent": rgba(accent, 62), "gradient_stop": "0.62", - "background": cls._blend(card_bg, accent, 0.10).name(), - "border": cls._blend(border, accent, 0.35).name(), - "dirty_background": cls._blend(card_bg, warning, 0.18).name(), - "dirty_border": cls._blend(border, warning, 0.70).name(), + "background": blend_colors(card_bg, accent, 0.10).name(), + "border": blend_colors(border, accent, 0.35).name(), + "dirty_background": blend_colors(card_bg, warning, 0.18).name(), + "dirty_border": blend_colors(border, warning, 0.70).name(), "foreground": foreground.name(), - "muted": cls._blend(card_bg, foreground, 0.66).name(), + "muted": blend_colors(card_bg, foreground, 0.66).name(), "shadow": "#00000078", "shadow_blur": "18", "shadow_y_offset": "2", } accent = accent.darker(118) - card_bg = cls._theme_color(theme, "CARD_BG", QColor("#ffffff")) - border = cls._theme_color(theme, "BORDER", QColor("#d9e2ec")) + card_bg = theme_color(theme, "CARD_BG", QColor("#ffffff")) + border = theme_color(theme, "BORDER", QColor("#d9e2ec")) muted = QColor("#667085") - card_border = cls._blend(border, accent, 0.34) + card_border = blend_colors(border, accent, 0.34) dirty_background = QColor("#fff8db") - dirty_border = cls._blend(border, warning, 0.72) + dirty_border = blend_colors(border, warning, 0.72) return { "accent": accent.name(), "on_accent": on_primary.name(), "card_background": card_bg.name(), "card_border": card_border.name(), - "gradient_accent": cls._rgba(accent, 18), + "gradient_accent": rgba(accent, 18), "gradient_stop": "0.38", "background": card_bg.name(), "border": card_border.name(), @@ -867,45 +860,6 @@ class BeamlineStatePill(BECWidget, QWidget): "shadow_y_offset": "3", } - @staticmethod - def _is_light_theme(theme: Any, palette: QPalette) -> bool: - theme_name = str(getattr(theme, "theme", "")).lower() - if theme_name in {"light", "dark"}: - return theme_name == "light" - return palette.window().color().lightness() > 128 - - @staticmethod - def _rgba(color: QColor, alpha: int) -> str: - return f"rgba({color.red()}, {color.green()}, {color.blue()}, {max(0, min(255, alpha))})" - - @staticmethod - def _card_gradient(colors: dict[str, str]) -> str: - return ( - "qlineargradient(" - "x1:0, y1:0, x2:1, y2:0, " - f"stop:0 {colors['gradient_accent']}, " - f"stop:{colors['gradient_stop']} {colors['card_background']}, " - f"stop:1 {colors['card_background']}" - ")" - ) - - @staticmethod - def _theme_color(theme: Any, key: str, fallback: QColor) -> QColor: - if theme is None: - return fallback - color = theme.color(key, fallback.name()) - return color if isinstance(color, QColor) else QColor(str(color)) - - @staticmethod - def _blend(base: QColor, overlay: QColor, overlay_alpha: float) -> QColor: - overlay_alpha = max(0.0, min(1.0, overlay_alpha)) - base_alpha = 1.0 - overlay_alpha - return QColor( - round(base.red() * base_alpha + overlay.red() * overlay_alpha), - round(base.green() * base_alpha + overlay.green() * overlay_alpha), - round(base.blue() * base_alpha + overlay.blue() * overlay_alpha), - ) - def cleanup(self) -> None: if self._state_name is not None: self.bec_dispatcher.disconnect_slot( @@ -1236,9 +1190,14 @@ class BeamlineStateManager(BECWidget, QWidget): @SafeSlot() def open_device_filter_dialog(self) -> None: - dialog = DeviceFilterDialog( - self._available_devices(), self._selected_devices, self._device_filter_text, self + devices = sorted( + { + device + for state in self._state_configs.values() + if (device := self._state_device(state)) is not None + } ) + dialog = DeviceFilterDialog(devices, self._selected_devices, self._device_filter_text, self) if dialog.exec() != QDialog.Accepted: return self._selected_devices = dialog.selected_devices() @@ -1299,20 +1258,17 @@ class BeamlineStateManager(BECWidget, QWidget): else: hidden_names.append(name) - self._apply_row_visibility(visible_names, hidden_names) - self._empty_label.setVisible( - not visible_names and not (self._hidden_expanded and hidden_names) - ) - self._view.setVisible(bool(visible_names) or (self._hidden_expanded and bool(hidden_names))) - self._refresh_hidden_summary(hidden_count=len(hidden_names)) - - def _apply_row_visibility(self, visible_names: list[str], hidden_names: list[str]) -> None: visible_set = set(visible_names) show_hidden = self._hidden_expanded and bool(hidden_names) for row, name in enumerate(self._state_order): hidden_by_filter = name not in visible_set self._view.setRowHidden(row, hidden_by_filter and not show_hidden) self._sync_pill_item_size(name) + self._empty_label.setVisible( + not visible_names and not (self._hidden_expanded and hidden_names) + ) + self._view.setVisible(bool(visible_names) or (self._hidden_expanded and bool(hidden_names))) + self._refresh_hidden_summary(hidden_count=len(hidden_names)) def _sync_pill_item_size(self, name: str) -> None: index = self._model.index_for_name(name) @@ -1412,14 +1368,6 @@ class BeamlineStateManager(BECWidget, QWidget): f"{hidden_count} {suffix} hidden by filters. {action} hidden states." ) - def _available_devices(self) -> list[str]: - devices = { - device - for state in self._state_configs.values() - if (device := self._state_device(state)) is not None - } - return sorted(devices) - @staticmethod def _state_device(state: dict[str, Any]) -> str | None: parameters = state.get("parameters") diff --git a/bec_widgets/widgets/services/beamline_states/dialogs.py b/bec_widgets/widgets/services/beamline_states/dialogs.py index cf9611fb..cded0360 100644 --- a/bec_widgets/widgets/services/beamline_states/dialogs.py +++ b/bec_widgets/widgets/services/beamline_states/dialogs.py @@ -68,7 +68,16 @@ class AddBeamlineStateDialog(QDialog): self._tolerance.setRange(0.0, 1_000_000_000) self._tolerance.setDecimals(6) self._tolerance.setValue(0.1) - for field in self._input_fields(): + for field in ( + self._type_combo, + self._name, + self._title, + self._device, + self._signal, + self._low_limit, + self._high_limit, + self._tolerance, + ): field.setFixedWidth(280) for spin_box in (self._low_limit, self._high_limit, self._tolerance): spin_box.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) @@ -99,7 +108,7 @@ class AddBeamlineStateDialog(QDialog): def config(self) -> bl_states.DeviceStateConfig | bl_states.DeviceWithinLimitsStateConfig: state_type = self._type_combo.currentData() name = self._state_name() - title = self._optional_text(self._title) + title = self._title.text().strip() or None device = self._selected_device() signal = self._selected_signal() @@ -149,7 +158,8 @@ class AddBeamlineStateDialog(QDialog): current_name = self._name.text().strip() if current_name and current_name != self._auto_generated_name: return - generated_name = f"{self._normalize_identifier(device)}_{self._state_name_suffix()}" + suffix = "limits" if self._type_combo.currentData() == "device_within_limits" else "state" + generated_name = f"{self._normalize_identifier(device)}_{suffix}" self._auto_generated_name = generated_name self._name.setText(generated_name) @@ -194,11 +204,6 @@ class AddBeamlineStateDialog(QDialog): ) return signal - @staticmethod - def _optional_text(line_edit: QLineEdit) -> str | None: - value = line_edit.text().strip() - return value or None - @staticmethod def _normalize_identifier(value: str) -> str: name = re.sub(r"\W+", "_", value.strip()) @@ -209,23 +214,6 @@ class AddBeamlineStateDialog(QDialog): name = f"state_{name}" return name - def _state_name_suffix(self) -> str: - if self._type_combo.currentData() == "device_within_limits": - return "limits" - return "state" - - def _input_fields(self) -> tuple[QWidget, ...]: - return ( - self._type_combo, - self._name, - self._title, - self._device, - self._signal, - self._low_limit, - self._high_limit, - self._tolerance, - ) - class StatusFilterDialog(QDialog): """Dialog for selecting visible beamline state statuses.""" diff --git a/tests/unit_tests/test_beamline_state_pill.py b/tests/unit_tests/test_beamline_state_pill.py index 50efed90..8d12e56b 100644 --- a/tests/unit_tests/test_beamline_state_pill.py +++ b/tests/unit_tests/test_beamline_state_pill.py @@ -114,7 +114,7 @@ def test_beamline_state_pill_expands_and_emits_updated_limits(qtbot, mocked_clie assert isinstance(widget._device_edit, DeviceComboBox) assert isinstance(widget._signal_edit, SignalComboBox) assert widget._device_edit.currentText() == "samx" - for field in widget._settings_input_fields(): + for field in widget._settings_fields: assert field.minimumWidth() == widget._SETTINGS_FIELD_WIDTH assert field.sizePolicy().horizontalPolicy() == QSizePolicy.Policy.Expanding assert widget.edited_parameters()["high_limit"] == 20.0 @@ -477,7 +477,7 @@ def test_beamline_state_manager_status_filter_reacts_to_state_changes(qtbot, moc assert widget._view.isRowHidden(widget._model.index_for_name("limits").row()) -def test_beamline_state_manager_filters_devices(qtbot, mocked_client): +def test_beamline_state_manager_filters_devices(qtbot, mocked_client, monkeypatch): widget = BeamlineStateManager(client=mocked_client) qtbot.addWidget(widget) @@ -506,7 +506,26 @@ def test_beamline_state_manager_filters_devices(qtbot, mocked_client): assert not widget._hidden_summary.isHidden() assert "1 state is hidden" in widget._hidden_summary.text() - assert widget._available_devices() == ["samx", "samy"] + + captured = {} + + class FakeDeviceFilterDialog: + def __init__(self, devices, selected_devices, device_filter_text, parent): + captured["devices"] = devices + captured["selected_devices"] = selected_devices + captured["device_filter_text"] = device_filter_text + captured["parent"] = parent + + def exec(self): + return 0 + + monkeypatch.setattr(pill_module, "DeviceFilterDialog", FakeDeviceFilterDialog) + + widget.open_device_filter_dialog() + + assert captured["devices"] == ["samx", "samy"] + assert captured["device_filter_text"] == "samx" + assert captured["parent"] is widget def test_beamline_state_manager_updates_state_parameters(qtbot, mocked_client): diff --git a/tests/unit_tests/test_color_utils.py b/tests/unit_tests/test_color_utils.py index fc68acb6..46b55f15 100644 --- a/tests/unit_tests/test_color_utils.py +++ b/tests/unit_tests/test_color_utils.py @@ -2,11 +2,18 @@ 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, + blend_colors, + 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 @@ -82,6 +89,33 @@ def test_rgba_to_hex(): assert Colors.rgba_to_hex(255, 87, 51) == "#FF5733FF" +def test_theme_name_and_color_helpers(qtbot): + class Theme: + theme = "light" + + def color(self, key, fallback): + return QColor("#123456") if key == "FG" else fallback + + app = QApplication.instance() + original_theme = getattr(app, "theme", None) + app.theme = Theme() + + try: + assert get_theme_name() == "light" + assert theme_color(Theme(), "FG", QColor("#ffffff")).name() == "#123456" + assert theme_color("light", "FG", QColor("#ffffff")).name() == "#ffffff" + finally: + if original_theme is None: + delattr(app, "theme") + else: + app.theme = original_theme + + +def test_qss_rgba_and_blend_helpers(): + assert rgba(QColor("#010203"), 300) == "rgba(1, 2, 3, 255)" + assert blend_colors(QColor("#000000"), QColor("#ffffff"), 0.5).name() == "#808080" + + def test_canonical_colormap_name_case_insensitive(): available = Colors.list_available_colormaps() presets = Colors.list_available_gradient_presets()