1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-22 00:04:35 +02:00

Compare commits

..

7 Commits

Author SHA1 Message Date
semantic-release 162e0ae78b 0.100.0
Automatically generated by python-semantic-release
2024-09-01 08:14:47 +00:00
wakonig_k 99d5e8e71c docs(becwidget): improvements to the bec widget base class docs; fixed type hint import for sphinx 2024-08-31 21:42:08 +02:00
wakonig_k 6c1f89ad39 fix(pyqt slot): removed slot decorator to avoid problems with pyqt6 2024-08-31 14:51:12 +02:00
wakonig_k 7fb938a850 feat(theme): added theme handler to bec widget base class; added tests 2024-08-31 14:32:38 +02:00
semantic-release 08c3d7d175 0.99.15
Automatically generated by python-semantic-release
2024-08-31 09:14:46 +00:00
wakonig_k af23e74f71 fix(theme): update pg axes on theme update 2024-08-31 11:11:13 +02:00
wakonig_k 0bf1cf9b8a fix(positioner_box): fixed positioner box dialog; added test; closes #332 2024-08-31 09:45:10 +02:00
14 changed files with 265 additions and 62 deletions
+22 -26
View File
@@ -1,5 +1,27 @@
# CHANGELOG
## v0.100.0 (2024-09-01)
### Documentation
* docs(becwidget): improvements to the bec widget base class docs; fixed type hint import for sphinx ([`99d5e8e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/99d5e8e71c7f89a53d7967126f4056dde005534c))
### Feature
* feat(theme): added theme handler to bec widget base class; added tests ([`7fb938a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/7fb938a8506685278ee5eeb6fe9a03f74b713cf8))
### Fix
* fix(pyqt slot): removed slot decorator to avoid problems with pyqt6 ([`6c1f89a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6c1f89ad39b7240ab1d1c1123422b99ae195bf01))
## v0.99.15 (2024-08-31)
### Fix
* fix(theme): update pg axes on theme update ([`af23e74`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/af23e74f71152f4abc319ab7b45e65deefde3519))
* fix(positioner_box): fixed positioner box dialog; added test; closes #332 ([`0bf1cf9`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/0bf1cf9b8ab2f9171d5ff63d4e3672eb93e9a5fa))
## v0.99.14 (2024-08-30)
### Fix
@@ -129,29 +151,3 @@
* fix(color maps): color maps should take the background color into account; fixed min colors to 10 ([`060935f`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/060935ffc5472a958c337bf60834c5291f104ece))
## v0.99.2 (2024-08-27)
### Ci
* ci: additional tests are not allowed to fail ([`bb385f0`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/bb385f07ca18904461a541b5cadde05398c84438))
### Fix
* fix(widgets): fixed default theme for widgets
If not theme is set, the init of the BECWidget base class sets the default theme to "dark" ([`cf28730`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/cf28730515e3c2d5914e0205768734c578711e5c))
## v0.99.1 (2024-08-27)
### Fix
* fix(crosshair): emit all crosshair events, not just line coordinates ([`2265458`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/2265458dcc57970db18c62619f5877d542d72e81))
## v0.99.0 (2024-08-25)
### Documentation
* docs(darkmodebutton): added dark mode button docs ([`406c263`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/406c263746f0e809c1a4d98356c48f40428c23d7))
### Refactor
* refactor(darkmodebutton): renamed set_dark_mode_enabled to toggle_dark_mode ([`c70724a`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/c70724a456900bcb06b040407a2c5d497e49ce77))
+2
View File
@@ -1,4 +1,6 @@
# pylint: disable=no-name-in-module
from __future__ import annotations
import os
from abc import ABC, abstractmethod
from collections import defaultdict
+56 -2
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from qtpy.QtCore import Slot
from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
@@ -11,7 +14,30 @@ class BECWidget(BECConnector):
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
ICON_NAME = "widgets"
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
def __init__(
self,
client=None,
config: ConnectionConfig = None,
gui_id: str = None,
theme_update: bool = False,
):
"""
Base class for all BEC widgets. This class should be used as a mixin class for all BEC widgets, e.g.:
>>> class MyWidget(BECWidget, QWidget):
>>> def __init__(self, parent=None, client=None, config=None, gui_id=None):
>>> super().__init__(client=client, config=config, gui_id=gui_id)
>>> QWidget.__init__(self, parent=parent)
Args:
client(BECClient, optional): The BEC client.
config(ConnectionConfig, optional): The connection configuration.
gui_id(str, optional): The GUI ID.
theme_update(bool, optional): Whether to subscribe to theme updates. Defaults to False. When set to True, the
widget's apply_theme method will be called when the theme changes.
"""
if not isinstance(self, QWidget):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
super().__init__(client, config, gui_id)
@@ -19,7 +45,35 @@ class BECWidget(BECConnector):
# Set the theme to auto if it is not set yet
app = QApplication.instance()
if not hasattr(app, "theme"):
set_theme("dark")
set_theme("auto")
if theme_update:
self._connect_to_theme_change()
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._update_theme)
def _update_theme(self, theme: str):
"""Update the theme."""
if theme is None:
qapp = QApplication.instance()
if hasattr(qapp, "theme"):
theme = qapp.theme["theme"]
else:
theme = "dark"
self.apply_theme(theme)
@Slot(str)
def apply_theme(self, theme: str):
"""
Apply the theme to the widget.
Args:
theme(str, optional): The theme to be applied.
"""
def cleanup(self):
"""Cleanup the widget."""
+20 -21
View File
@@ -19,6 +19,17 @@ def get_theme_palette():
return bec_qthemes.load_palette(theme)
def _theme_update_callback():
"""
Internal callback function to update the theme based on the system theme.
"""
app = QApplication.instance()
# pylint: disable=protected-access
app.theme["theme"] = app.os_listener._theme.lower()
app.theme_signal.theme_updated.emit(app.theme["theme"])
apply_theme(app.os_listener._theme.lower())
def set_theme(theme: Literal["dark", "light", "auto"]):
"""
Set the theme for the application.
@@ -27,21 +38,17 @@ def set_theme(theme: Literal["dark", "light", "auto"]):
theme (Literal["dark", "light", "auto"]): The theme to set. "auto" will automatically switch between dark and light themes based on the system theme.
"""
app = QApplication.instance()
bec_qthemes.setup_theme(theme)
pg.setConfigOption("background", "w" if app.theme["theme"] == "light" else "k")
bec_qthemes.setup_theme(theme, install_event_filter=False)
app.theme_signal.theme_updated.emit(theme)
apply_theme(theme)
# pylint: disable=protected-access
if theme != "auto":
return
def callback():
app.theme["theme"] = listener._theme.lower()
apply_theme(listener._theme.lower())
listener = OSThemeSwitchListener(callback)
app.installEventFilter(listener)
if not hasattr(app, "os_listener") or app.os_listener is None:
app.os_listener = OSThemeSwitchListener(_theme_update_callback)
app.installEventFilter(app.os_listener)
def apply_theme(theme: Literal["dark", "light"]):
@@ -53,20 +60,12 @@ def apply_theme(theme: Literal["dark", "light"]):
children = itertools.chain.from_iterable(
top.findChildren(pg.GraphicsLayoutWidget) for top in app.topLevelWidgets()
)
pg.setConfigOptions(
foreground="d" if theme == "dark" else "k", background="k" if theme == "dark" else "w"
)
for pg_widget in children:
pg_widget.setBackground("k" if theme == "dark" else "w")
dark_mode_buttons = [
button
for button in app.topLevelWidgets()
if hasattr(button, "dark_mode_enabled")
and hasattr(button, "mode_button")
and isinstance(button.mode_button, (QPushButton, QToolButton))
]
for button in dark_mode_buttons:
button.dark_mode_enabled = theme == "dark"
button.update_mode_button()
# now define stylesheet according to theme and apply it
style = bec_qthemes.load_stylesheet(theme)
app.setStyleSheet(style)
@@ -1,3 +1,5 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import Property, Qt, Slot
from qtpy.QtWidgets import QApplication, QHBoxLayout, QPushButton, QToolButton, QWidget
@@ -18,7 +20,7 @@ class DarkModeButton(BECWidget, QWidget):
gui_id: str | None = None,
toolbar: bool = False,
) -> None:
super().__init__(client=client, gui_id=gui_id)
super().__init__(client=client, gui_id=gui_id, theme_update=True)
QWidget.__init__(self, parent)
self._dark_mode_enabled = False
@@ -39,6 +41,17 @@ class DarkModeButton(BECWidget, QWidget):
self.setLayout(self.layout)
self.setFixedSize(40, 40)
@Slot(str)
def apply_theme(self, theme: str):
"""
Apply the theme to the widget.
Args:
theme(str, optional): The theme to be applied.
"""
self.dark_mode_enabled = theme == "dark"
self.update_mode_button()
def _get_qapp_dark_mode_state(self) -> bool:
"""
Get the dark mode state from the QApplication.
+34 -1
View File
@@ -2,10 +2,11 @@ from __future__ import annotations
from typing import Literal, Optional
import bec_qthemes
import pyqtgraph as pg
from pydantic import BaseModel, Field
from qtpy.QtCore import Signal, Slot
from qtpy.QtWidgets import QWidget
from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.utils import BECConnector, ConnectionConfig
from bec_widgets.utils.crosshair import Crosshair
@@ -98,6 +99,38 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
self.add_legend()
self.crosshair = None
self._connect_to_theme_change()
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._update_theme)
@Slot(str)
def _update_theme(self, theme: str):
"""Update the theme."""
if theme is None:
qapp = QApplication.instance()
if hasattr(qapp, "theme"):
theme = qapp.theme["theme"]
else:
theme = "dark"
self.apply_theme(theme)
def apply_theme(self, theme: str):
"""
Apply the theme to the plot widget.
Args:
theme(str, optional): The theme to be applied.
"""
palette = bec_qthemes.load_palette(theme)
text_pen = pg.mkPen(color=palette.text().color())
for axis in ["left", "bottom", "right", "top"]:
self.plot_item.getAxis(axis).setPen(text_pen)
self.plot_item.getAxis(axis).setTextPen(text_pen)
def set(self, **kwargs) -> None:
"""
@@ -46,6 +46,7 @@ class PositionerBox(BECWidget, QWidget):
self.get_bec_shortcuts()
self._device = ""
self._limits = None
self._dialog = None
self.init_ui()
@@ -87,17 +88,18 @@ class PositionerBox(BECWidget, QWidget):
def _open_dialog_selection(self):
"""Open dialog window for positioner selection"""
dialog = QDialog(self)
dialog.setWindowTitle("Positioner Selection")
self._dialog = QDialog(self)
self._dialog.setWindowTitle("Positioner Selection")
layout = QVBoxLayout()
line_edit = DeviceLineEdit(self, client=self.client, device_filter="Positioner")
line_edit.textChanged.connect(self._positioner_changed)
line_edit.textChanged.connect(self.set_positioner)
layout.addWidget(line_edit)
close_button = QPushButton("Close")
close_button.clicked.connect(dialog.accept)
close_button.clicked.connect(self._dialog.accept)
layout.addWidget(close_button)
dialog.setLayout(layout)
dialog.exec()
self._dialog.setLayout(layout)
self._dialog.exec()
self._dialog = None
def init_device(self):
"""Init the device view and readback"""
@@ -39,6 +39,10 @@ integrated with the BEC system by providing:
from the `BECIPythonClient` via CLI, providing powerful control and automation capabilities. For example, you can
remotely adjust widget settings, start/stop operations, or query the widgets status directly from the command line.
5. **Reacting to Theme Changes**: The base class provides a dedicated input flag to subscribe to theme changes, allowing
your widget to adapt its appearance based on the current theme (e.g., light or dark mode) and can even synchronize with the user's OS settings. The widget-specific logic can then
be implemented in the `apply_theme` method, which is called whenever the theme changes. This ensures a consistent user experience across different themes and environments.
Heres a basic example of a widget inheriting
from [`BECWidget`](https://bec.readthedocs.io/projects/bec-widgets/en/latest/api_reference/_autosummary/bec_widgets.utils.bec_widget.BECWidget.html#bec_widgets.utils.bec_widget.BECWidget):
@@ -46,10 +50,9 @@ from [`BECWidget`](https://bec.readthedocs.io/projects/bec-widgets/en/latest/api
from bec_widgets.utils.bec_widget import BECWidget
from qtpy.QtWidgets import QWidget, QVBoxLayout
class MyWidget(BECWidget, QWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(*args, **kwargs)
super().__init__(*args, **kwargs) # disable theme updates
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts() # Initialize BEC shortcuts
self.init_ui()
@@ -58,6 +61,26 @@ class MyWidget(BECWidget, QWidget):
layout = QVBoxLayout(self)
# Add more UI components here
self.setLayout(layout)
# To enable theme updates, set theme_update=True, e.g.:
class MyDynamicWidget(BECWidget, QWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(*args, theme_update=True, **kwargs) # enable theme updates
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts() # Initialize BEC shortcuts
self.init_ui()
def init_ui(self):
layout = QVBoxLayout(self)
# Add more UI components here
self.setLayout(layout)
def apply_theme(self, theme):
# Implement theme-specific logic here
pass
```
### The Role of `BECConnector`
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "bec_widgets"
version = "0.99.14"
version = "0.100.0"
description = "BEC Widgets"
requires-python = ">=3.10"
classifiers = [
+2
View File
@@ -14,6 +14,8 @@ def qapplication(qtbot): # pylint: disable=unused-argument
qapp = QApplication.instance()
# qapp.quit()
qapp.processEvents()
if hasattr(qapp, "os_listener") and qapp.os_listener:
qapp.removeEventFilter(qapp.os_listener)
try:
qtbot.waitUntil(lambda: qapp.topLevelWidgets() == [])
except QtBotTimeoutError as exc:
+14
View File
@@ -2,6 +2,7 @@ from unittest import mock
import pytest
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import set_theme
from bec_widgets.widgets.dark_mode_button.dark_mode_button import DarkModeButton
@@ -70,3 +71,16 @@ def test_dark_mode_button_changes_theme(dark_mode_button):
dark_mode_button.toggle_dark_mode()
mocked_set_theme.assert_called_with("light")
def test_dark_mode_button_changes_on_os_theme_change(qtbot, dark_mode_button):
"""
Test that the dark mode button changes the theme correctly when the OS theme changes.
"""
qapp = QApplication.instance()
assert dark_mode_button.dark_mode_enabled is False
assert dark_mode_button.mode_button.toolTip() == "Set Dark Mode"
qapp.theme_signal.theme_updated.emit("dark")
qtbot.wait(100)
assert dark_mode_button.dark_mode_enabled is True
assert dark_mode_button.mode_button.toolTip() == "Set Light Mode"
+23
View File
@@ -4,8 +4,11 @@ import pytest
from bec_lib.device import Positioner
from bec_lib.endpoints import MessageEndpoints
from bec_lib.messages import ScanQueueMessage
from qtpy.QtCore import Qt, QTimer
from qtpy.QtGui import QValidator
from qtpy.QtWidgets import QPushButton
from bec_widgets.widgets.device_line_edit.device_line_edit import DeviceLineEdit
from bec_widgets.widgets.positioner_box.positioner_box import PositionerBox
from bec_widgets.widgets.positioner_box.positioner_control_line import PositionerControlLine
@@ -128,3 +131,23 @@ def test_positioner_control_line(qtbot, mocked_client):
assert db.ui.device_box.height() == 60
assert db.ui.device_box.width() == 600
def test_positioner_box_open_dialog_selection(qtbot, positioner_box):
"""Test open positioner edit"""
# Use a timer to close the dialog after it opens
def close_dialog():
# pylint: disable=protected-access
assert positioner_box._dialog is not None
qtbot.waitUntil(lambda: positioner_box._dialog.isVisible() is True, timeout=1000)
line_edit = positioner_box._dialog.findChild(DeviceLineEdit)
line_edit.setText("samy")
close_button = positioner_box._dialog.findChild(QPushButton)
assert close_button.text() == "Close"
qtbot.mouseClick(close_button, Qt.LeftButton)
# Execute the timer after the dialog opens to close it
QTimer.singleShot(100, close_dialog)
qtbot.mouseClick(positioner_box.ui.tool_button, Qt.LeftButton)
assert positioner_box.device == "samy"
+2 -2
View File
@@ -61,7 +61,7 @@ def test_vscode_cleanup(qtbot, patched_vscode_process):
vscode_patched, mock_killpg = patched_vscode_process
vscode_patched.process.pid = 123
vscode_patched.process.poll.return_value = None
vscode_patched.cleanup()
vscode_patched.cleanup_vscode()
mock_killpg.assert_called_once_with(123, 15)
vscode_patched.process.wait.assert_called_once()
@@ -70,6 +70,6 @@ def test_close_event_on_terminated_code(qtbot, patched_vscode_process):
vscode_patched, mock_killpg = patched_vscode_process
vscode_patched.process.pid = 123
vscode_patched.process.poll.return_value = 0
vscode_patched.cleanup()
vscode_patched.cleanup_vscode()
mock_killpg.assert_not_called()
vscode_patched.process.wait.assert_not_called()
+42
View File
@@ -2,8 +2,11 @@ from unittest.mock import MagicMock, patch
import pyqtgraph as pg
import pytest
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication
from bec_widgets.qt_utils.settings_dialog import SettingsDialog
from bec_widgets.utils.colors import apply_theme, get_theme_palette, set_theme
from bec_widgets.widgets.figure.plots.axis_settings import AxisSettings
from bec_widgets.widgets.waveform.waveform_popups.curve_dialog.curve_dialog import CurveSettings
from bec_widgets.widgets.waveform.waveform_widget import BECWaveformWidget
@@ -460,3 +463,42 @@ def test_axis_dialog_set_properties(qtbot, waveform_widget):
assert waveform_widget._config_dict["axis"]["y_scale"] == "linear"
assert waveform_widget._config_dict["axis"]["x_lim"] == (5, 15)
assert waveform_widget._config_dict["axis"]["y_lim"] == (5, 15)
def test_waveform_widget_theme_update(qtbot, waveform_widget):
"""Test theme update for waveform widget."""
qapp = QApplication.instance()
# Set the theme directly; equivalent to clicking the dark mode button
# The background color should be black and the axis color should be white
set_theme("dark")
palette = get_theme_palette()
waveform_color_dark = waveform_widget.waveform.plot_item.getAxis("left").pen().color()
bg_color = waveform_widget.fig.backgroundBrush().color()
assert bg_color == QColor("black")
assert waveform_color_dark == palette.text().color()
# Set the theme to light; equivalent to clicking the light mode button
# The background color should be white and the axis color should be black
set_theme("light")
palette = get_theme_palette()
waveform_color_light = waveform_widget.waveform.plot_item.getAxis("left").pen().color()
bg_color = waveform_widget.fig.backgroundBrush().color()
assert bg_color == QColor("white")
assert waveform_color_light == palette.text().color()
assert waveform_color_dark != waveform_color_light
# Set the theme to auto; equivalent starting the application with no theme set
set_theme("auto")
# Simulate that the OS theme changes to dark
qapp.theme_signal.theme_updated.emit("dark")
apply_theme("dark")
# The background color should be black and the axis color should be white
# As we don't have access to the listener here, we can't test the palette change. Instead,
# we compare the waveform color to the dark theme color
waveform_color = waveform_widget.waveform.plot_item.getAxis("left").pen().color()
bg_color = waveform_widget.fig.backgroundBrush().color()
assert bg_color == QColor("black")
assert waveform_color == waveform_color_dark