refactor(colors): theme utils adjustments and application in notification banner and beamline state manager

This commit is contained in:
2026-06-05 18:11:00 +02:00
parent 9047d69722
commit 7b16cda29a
6 changed files with 207 additions and 186 deletions
+37 -3
View File
@@ -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]:
@@ -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
@@ -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")
@@ -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."""
+22 -3
View File
@@ -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):
+36 -2
View File
@@ -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()