mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-06-06 21:38:40 +02:00
refactor(colors): theme utils adjustments and application in notification banner and beamline state manager
This commit is contained in:
@@ -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]:
|
||||
|
||||
+4
-6
@@ -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."""
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user