Compare commits

..

3 Commits

8 changed files with 40 additions and 161 deletions
-22
View File
@@ -1,28 +1,6 @@
# CHANGELOG
## v3.12.2 (2026-05-21)
### Bug Fixes
- **toggle**: Disable styling implemented
([`9eb0541`](https://github.com/bec-project/bec_widgets/commit/9eb05416ab68dcb88732dca8974c665030d34e0b))
## v3.12.1 (2026-05-21)
### Bug Fixes
- **device_input**: Correct cleanup unsubscribe
([`56427a7`](https://github.com/bec-project/bec_widgets/commit/56427a7f0c3a89fe847d415c8b45212e663434c4))
- **device_input**: Ensure callback is removed after cleanup
([`d99db7d`](https://github.com/bec-project/bec_widgets/commit/d99db7d04208945b86a39d65022b211ba093caed))
- **signal_combobox**: Signature matched for update_signals_from_filters
([`a976837`](https://github.com/bec-project/bec_widgets/commit/a976837cff612349f2a3f17900903c203bc3d250))
## v3.12.0 (2026-05-20)
### Bug Fixes
@@ -9,7 +9,7 @@ from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority
from bec_lib.device import Signal as BECSignal
from bec_lib.logger import bec_logger
from pydantic import Field, field_validator
from qtpy.QtCore import QSize, QStringListModel, Signal, Slot
from qtpy.QtCore import QSize, QStringListModel, Qt, Signal, Slot
from qtpy.QtWidgets import QComboBox, QCompleter, QSizePolicy
from bec_widgets.utils.bec_connector import ConnectionConfig
@@ -191,6 +191,13 @@ class DeviceComboBox(BECWidget, QComboBox):
if self.config.autocomplete:
self.autocomplete = True
self._callback_id = self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.on_device_update
)
self.device_config_update.connect(
self.update_devices_from_filters, Qt.ConnectionType.QueuedConnection
)
if available_devices is not None:
self.set_available_devices(available_devices)
@@ -216,10 +223,6 @@ class DeviceComboBox(BECWidget, QComboBox):
else:
self.setCurrentText("")
self._callback_id = self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.on_device_update
)
self.device_config_update.connect(self.update_devices_from_filters)
self.currentTextChanged.connect(self.check_validity)
self.check_validity(self.currentText())
@@ -255,6 +258,9 @@ class DeviceComboBox(BECWidget, QComboBox):
@SafeSlot()
def update_devices_from_filters(self):
"""Refresh the available device list from current device/readout/signal filters."""
if self._callback_id is None or getattr(self, "_destroyed", False):
return
self.config.device_filter = [entry.value for entry in self.device_filter]
self.config.readout_filter = [entry.value for entry in self.readout_filter]
self.config.signal_class_filter = self.signal_class_filter
@@ -497,9 +503,8 @@ class DeviceComboBox(BECWidget, QComboBox):
def cleanup(self):
"""Cleanup the widget."""
if self._callback_id is not None:
callback_id = self._callback_id
self.bec_dispatcher.client.callbacks.remove(self._callback_id)
self._callback_id = None
self.bec_dispatcher.client.callbacks.remove(callback_id)
super().cleanup()
def get_current_device(self) -> object:
@@ -77,6 +77,7 @@ class SignalComboBox(BECWidget, QComboBox):
device_signal_changed = Signal(str)
signal_reset = Signal()
device_config_update = Signal()
def __init__(
self,
@@ -138,7 +139,10 @@ class SignalComboBox(BECWidget, QComboBox):
self.autocomplete = True
self._device_update_register = self.bec_dispatcher.client.callbacks.register(
EventType.DEVICE_UPDATE, self.update_signals_from_filters
EventType.DEVICE_UPDATE, self.on_device_update
)
self.device_config_update.connect(
self.update_signals_from_filters, Qt.ConnectionType.QueuedConnection
)
self.currentTextChanged.connect(self.on_text_changed)
@@ -197,21 +201,19 @@ class SignalComboBox(BECWidget, QComboBox):
self.update_signals_from_filters()
@SafeSlot()
@SafeSlot(str, dict)
def update_signals_from_filters(self, action: str | None = None, content: dict | None = None):
@SafeSlot(dict, dict)
def update_signals_from_filters(
self, content: dict | None = None, metadata: dict | None = None
):
"""Refresh available signals from the current device and filters.
Args:
action: Optional BEC device update action. If provided, only device list changing
actions trigger a refresh.
content: Optional callback payload from BEC device updates. Currently unused.
metadata: Optional callback metadata from BEC device updates. Currently unused.
"""
if self._device_update_register is None or getattr(self, "_destroyed", False):
return
if action is not None and action not in ["add", "remove", "reload"]:
return
self.config.signal_filter = [kind.name for kind in self.signal_filter]
if self._signal_class_filter:
@@ -252,6 +254,13 @@ class SignalComboBox(BECWidget, QComboBox):
),
)
def on_device_update(self, action: str, content: dict) -> None:
"""Refresh filters when BEC reports device configuration changes."""
if self._device_update_register is None or getattr(self, "_destroyed", False):
return
if action in ["add", "remove", "reload"]:
self.device_config_update.emit()
@Property(str)
def device(self) -> str:
"""Selected device."""
@@ -594,9 +603,8 @@ class SignalComboBox(BECWidget, QComboBox):
def cleanup(self):
"""Cleanup the widget."""
if self._device_update_register is not None:
callback_id = self._device_update_register
self.bec_dispatcher.client.callbacks.remove(self._device_update_register)
self._device_update_register = None
self.bec_dispatcher.client.callbacks.remove(callback_id)
super().cleanup()
@staticmethod
+9 -35
View File
@@ -1,6 +1,6 @@
import sys
from qtpy.QtCore import Property, QEasingCurve, QEvent, QPointF, QPropertyAnimation, Qt, Signal
from qtpy.QtCore import Property, QEasingCurve, QPointF, QPropertyAnimation, Qt, Signal
from qtpy.QtGui import QColor, QPainter
from qtpy.QtWidgets import QApplication, QWidget
@@ -41,22 +41,10 @@ class ToggleSwitch(QWidget):
theme = getattr(QApplication.instance(), "theme", None)
colors = theme.colors if theme else {}
self._active_track_color = self._theme_color(colors, "PRIMARY", QColor(33, 150, 243))
self._active_thumb_color = self._theme_color(colors, "ON_PRIMARY", QColor(255, 255, 255))
self._inactive_track_color = self._theme_color(colors, "SEPARATOR", QColor(200, 200, 200))
self._inactive_thumb_color = self._theme_color(colors, "ON_PRIMARY", QColor(255, 255, 255))
self._disabled_track_color = self._theme_color(colors, "DISABLED_BG", QColor(220, 220, 220))
self._disabled_thumb_color = self._theme_color(colors, "DISABLED_FG", QColor(150, 150, 150))
self._disabled_border_color = self._theme_color(
colors, "DISABLED_BORDER", QColor(170, 170, 170)
)
if hasattr(self, "_checked"):
self.update_colors()
@staticmethod
def _theme_color(colors: dict, key: str, fallback: QColor) -> QColor:
color = colors.get(key, fallback)
return color if isinstance(color, QColor) else QColor(color)
self._active_track_color = colors.get("PRIMARY", QColor(33, 150, 243))
self._active_thumb_color = colors.get("ON_PRIMARY", QColor(255, 255, 255))
self._inactive_track_color = colors.get("SEPARATOR", QColor(200, 200, 200))
self._inactive_thumb_color = colors.get("ON_PRIMARY", QColor(255, 255, 255))
@Property(bool)
def checked(self):
@@ -131,40 +119,29 @@ class ToggleSwitch(QWidget):
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setRenderHint(QPainter.Antialiasing)
# Draw track
painter.setBrush(self._track_color)
painter.setPen(self._disabled_border_color if not self.isEnabled() else Qt.PenStyle.NoPen)
painter.setPen(Qt.NoPen)
painter.drawRoundedRect(
0, 0, self.width(), self.height(), self.height() / 2, self.height() / 2
)
# Draw thumb
painter.setBrush(self._thumb_color)
painter.setPen(Qt.PenStyle.NoPen)
diameter = int(self.height() * 0.8)
painter.drawEllipse(int(self._thumb_pos.x()), int(self._thumb_pos.y()), diameter, diameter)
def mousePressEvent(self, event):
if self.isEnabled() and event.button() == Qt.MouseButton.LeftButton:
if event.button() == Qt.LeftButton:
self.checked = not self.checked
def update_colors(self):
if not self.isEnabled():
self._thumb_color = self._disabled_thumb_color
self._track_color = self._disabled_track_color
return
self._thumb_color = self.active_thumb_color if self._checked else self.inactive_thumb_color
self._track_color = self.active_track_color if self._checked else self.inactive_track_color
def changeEvent(self, event):
if event.type() == QEvent.Type.EnabledChange:
self.update_colors()
self.update()
super().changeEvent(event)
def get_thumb_pos(self, checked):
return QPointF(self.width() - self.height() + 3, 2) if checked else QPointF(3, 2)
@@ -190,7 +167,7 @@ class ToggleSwitch(QWidget):
if __name__ == "__main__": # pragma: no cover
from qtpy.QtWidgets import QHBoxLayout, QWidget
from qtpy.QtWidgets import QHBoxLayout, QVBoxLayout, QWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
@@ -200,12 +177,9 @@ if __name__ == "__main__": # pragma: no cover
widget = QWidget()
layout = QHBoxLayout(widget)
toggle = ToggleSwitch()
toggle_disabled = ToggleSwitch()
dark_mode_btn = DarkModeButton()
layout.addWidget(toggle)
layout.addWidget(toggle_disabled)
layout.addWidget(dark_mode_btn)
toggle_disabled.setEnabled(False)
window = QWidget()
window.setLayout(layout)
window.show()
+1 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "bec_widgets"
version = "3.12.2"
version = "3.12.0"
description = "BEC Widgets"
requires-python = ">=3.11"
classifiers = [
@@ -65,8 +65,6 @@ qtermwidget = ["pyside6_qtermwidget"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
@@ -139,23 +139,6 @@ def test_device_input_combobox_cleanup_unregisters_callback(qtbot, mocked_client
assert widget._callback_id is None
def test_device_input_combobox_cleanup_clears_callback_before_unregister(qtbot, mocked_client):
widget = DeviceComboBox(client=mocked_client)
qtbot.addWidget(widget)
callback_id = widget._callback_id
def assert_callback_cleared(removed_callback_id):
assert removed_callback_id == callback_id
assert widget._callback_id is None
with mock.patch.object(
mocked_client.callbacks, "remove", side_effect=assert_callback_cleared
) as remove_mock:
widget.cleanup()
remove_mock.assert_called_once_with(callback_id)
def test_get_device_from_input_combobox_init(device_input_combobox):
device_input_combobox.setCurrentIndex(0)
device_text = device_input_combobox.currentText()
@@ -196,50 +196,6 @@ def test_device_signal_input_base_cleanup(qtbot, mocked_client):
assert widget._device_update_register is None
def test_signal_combobox_cleanup_clears_callback_before_unregister(qtbot, mocked_client):
widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client)
callback_id = widget._device_update_register
def assert_callback_cleared(removed_callback_id):
assert removed_callback_id == callback_id
assert widget._device_update_register is None
with mock.patch.object(
mocked_client.callbacks, "remove", side_effect=assert_callback_cleared
) as remove_mock:
widget.cleanup()
remove_mock.assert_called_once_with(callback_id)
def test_signal_combobox_cleanup_blocks_in_flight_device_update(qtbot, mocked_client):
widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client)
callback_id = widget._device_update_register
def trigger_in_flight_update(_):
widget.update_signals_from_filters("reload", {})
with (
mock.patch.object(
mocked_client.callbacks, "remove", side_effect=trigger_in_flight_update
) as remove_mock,
mock.patch.object(widget, "_set_signal_groups") as set_signal_groups,
):
widget.cleanup()
remove_mock.assert_called_once_with(callback_id)
set_signal_groups.assert_not_called()
def test_signal_combobox_device_update_ignores_update_action(qtbot, mocked_client):
widget = create_widget(qtbot=qtbot, widget=SignalComboBox, client=mocked_client)
with mock.patch.object(widget, "_set_signal_groups") as set_signal_groups:
widget.update_signals_from_filters("update", {})
set_signal_groups.assert_not_called()
def test_signal_combobox_get_signal_name_with_item_data(qtbot, device_signal_combobox):
"""Test get_signal_name returns obj_name from item data when available."""
device_signal_combobox.include_normal_signals = True
-23
View File
@@ -36,26 +36,3 @@ def test_toggle_click(qtbot, toggle):
qtbot.mouseClick(toggle, Qt.LeftButton)
toggle.paintEvent(None)
assert toggle.checked is not init_state
def test_toggle_disabled_state_blocks_clicks_and_restores_colors(qtbot, toggle):
toggle.checked = True
assert toggle._track_color == toggle.active_track_color
assert toggle._thumb_color == toggle.active_thumb_color
toggle.setEnabled(False)
assert toggle._track_color == toggle._disabled_track_color
assert toggle._thumb_color == toggle._disabled_thumb_color
qtbot.mouseClick(toggle, Qt.LeftButton)
assert toggle.checked is True
assert toggle._track_color == toggle._disabled_track_color
assert toggle._thumb_color == toggle._disabled_thumb_color
toggle.setEnabled(True)
assert toggle.checked is True
assert toggle._track_color == toggle.active_track_color
assert toggle._thumb_color == toggle.active_thumb_color