mirror of
https://github.com/bec-project/bec_widgets.git
synced 2025-07-14 03:31:50 +02:00
feat(theme): added theme handler to bec widget base class; added tests
This commit is contained in:
@ -1,3 +1,4 @@
|
|||||||
|
from qtpy.QtCore import Slot
|
||||||
from qtpy.QtWidgets import QApplication, QWidget
|
from qtpy.QtWidgets import QApplication, QWidget
|
||||||
|
|
||||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||||
@ -11,7 +12,13 @@ class BECWidget(BECConnector):
|
|||||||
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
|
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
|
||||||
ICON_NAME = "widgets"
|
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,
|
||||||
|
):
|
||||||
if not isinstance(self, QWidget):
|
if not isinstance(self, QWidget):
|
||||||
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
|
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
|
||||||
super().__init__(client, config, gui_id)
|
super().__init__(client, config, gui_id)
|
||||||
@ -19,7 +26,36 @@ class BECWidget(BECConnector):
|
|||||||
# Set the theme to auto if it is not set yet
|
# Set the theme to auto if it is not set yet
|
||||||
app = QApplication.instance()
|
app = QApplication.instance()
|
||||||
if not hasattr(app, "theme"):
|
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)
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def apply_theme(self, theme: str):
|
||||||
|
"""
|
||||||
|
Apply the theme to the plot widget.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme(str, optional): The theme to be applied.
|
||||||
|
"""
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
"""Cleanup the widget."""
|
"""Cleanup the widget."""
|
||||||
|
@ -19,6 +19,17 @@ def get_theme_palette():
|
|||||||
return bec_qthemes.load_palette(theme)
|
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"]):
|
def set_theme(theme: Literal["dark", "light", "auto"]):
|
||||||
"""
|
"""
|
||||||
Set the theme for the application.
|
Set the theme for the application.
|
||||||
@ -27,23 +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.
|
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()
|
app = QApplication.instance()
|
||||||
bec_qthemes.setup_theme(theme)
|
bec_qthemes.setup_theme(theme, install_event_filter=False)
|
||||||
pg.setConfigOption("background", "w" if app.theme["theme"] == "light" else "k")
|
|
||||||
app.theme_signal.theme_updated.emit(theme)
|
app.theme_signal.theme_updated.emit(theme)
|
||||||
apply_theme(theme)
|
apply_theme(theme)
|
||||||
|
|
||||||
# pylint: disable=protected-access
|
|
||||||
if theme != "auto":
|
if theme != "auto":
|
||||||
return
|
return
|
||||||
|
|
||||||
def callback():
|
if not hasattr(app, "os_listener") or app.os_listener is None:
|
||||||
app.theme["theme"] = listener._theme.lower()
|
app.os_listener = OSThemeSwitchListener(_theme_update_callback)
|
||||||
app.theme_signal.theme_updated.emit(app.theme["theme"])
|
app.installEventFilter(app.os_listener)
|
||||||
apply_theme(listener._theme.lower())
|
|
||||||
|
|
||||||
listener = OSThemeSwitchListener(callback)
|
|
||||||
|
|
||||||
app.installEventFilter(listener)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_theme(theme: Literal["dark", "light"]):
|
def apply_theme(theme: Literal["dark", "light"]):
|
||||||
@ -55,21 +60,12 @@ def apply_theme(theme: Literal["dark", "light"]):
|
|||||||
children = itertools.chain.from_iterable(
|
children = itertools.chain.from_iterable(
|
||||||
top.findChildren(pg.GraphicsLayoutWidget) for top in app.topLevelWidgets()
|
top.findChildren(pg.GraphicsLayoutWidget) for top in app.topLevelWidgets()
|
||||||
)
|
)
|
||||||
pg.setConfigOptions(foreground="d" if theme == "dark" else "k")
|
pg.setConfigOptions(
|
||||||
|
foreground="d" if theme == "dark" else "k", background="k" if theme == "dark" else "w"
|
||||||
|
)
|
||||||
for pg_widget in children:
|
for pg_widget in children:
|
||||||
pg_widget.setBackground("k" if theme == "dark" else "w")
|
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
|
# now define stylesheet according to theme and apply it
|
||||||
style = bec_qthemes.load_stylesheet(theme)
|
style = bec_qthemes.load_stylesheet(theme)
|
||||||
app.setStyleSheet(style)
|
app.setStyleSheet(style)
|
||||||
|
@ -18,7 +18,7 @@ class DarkModeButton(BECWidget, QWidget):
|
|||||||
gui_id: str | None = None,
|
gui_id: str | None = None,
|
||||||
toolbar: bool = False,
|
toolbar: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(client=client, gui_id=gui_id)
|
super().__init__(client=client, gui_id=gui_id, theme_update=True)
|
||||||
QWidget.__init__(self, parent)
|
QWidget.__init__(self, parent)
|
||||||
|
|
||||||
self._dark_mode_enabled = False
|
self._dark_mode_enabled = False
|
||||||
@ -39,6 +39,17 @@ class DarkModeButton(BECWidget, QWidget):
|
|||||||
self.setLayout(self.layout)
|
self.setLayout(self.layout)
|
||||||
self.setFixedSize(40, 40)
|
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:
|
def _get_qapp_dark_mode_state(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Get the dark mode state from the QApplication.
|
Get the dark mode state from the QApplication.
|
||||||
|
@ -99,30 +99,32 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout):
|
|||||||
|
|
||||||
self.add_legend()
|
self.add_legend()
|
||||||
self.crosshair = None
|
self.crosshair = None
|
||||||
self.connect_to_theme_change()
|
self._connect_to_theme_change()
|
||||||
self.apply_theme()
|
|
||||||
|
|
||||||
def connect_to_theme_change(self):
|
def _connect_to_theme_change(self):
|
||||||
"""Connect to the theme change signal."""
|
"""Connect to the theme change signal."""
|
||||||
qapp = QApplication.instance()
|
qapp = QApplication.instance()
|
||||||
if hasattr(qapp, "theme_signal"):
|
if hasattr(qapp, "theme_signal"):
|
||||||
qapp.theme_signal.theme_updated.connect(self.apply_theme)
|
qapp.theme_signal.theme_updated.connect(self._update_theme)
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
@Slot()
|
def _update_theme(self, theme: str):
|
||||||
def apply_theme(self, theme: str | None = None):
|
"""Update the theme."""
|
||||||
"""
|
|
||||||
Apply the theme to the plot widget.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
theme(str, optional): The theme to be applied.
|
|
||||||
"""
|
|
||||||
if theme is None:
|
if theme is None:
|
||||||
qapp = QApplication.instance()
|
qapp = QApplication.instance()
|
||||||
if hasattr(qapp, "theme"):
|
if hasattr(qapp, "theme"):
|
||||||
theme = qapp.theme["theme"]
|
theme = qapp.theme["theme"]
|
||||||
else:
|
else:
|
||||||
theme = "dark"
|
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)
|
palette = bec_qthemes.load_palette(theme)
|
||||||
text_pen = pg.mkPen(color=palette.text().color())
|
text_pen = pg.mkPen(color=palette.text().color())
|
||||||
|
|
||||||
|
@ -14,6 +14,8 @@ def qapplication(qtbot): # pylint: disable=unused-argument
|
|||||||
qapp = QApplication.instance()
|
qapp = QApplication.instance()
|
||||||
# qapp.quit()
|
# qapp.quit()
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
|
if hasattr(qapp, "os_listener") and qapp.os_listener:
|
||||||
|
qapp.removeEventFilter(qapp.os_listener)
|
||||||
try:
|
try:
|
||||||
qtbot.waitUntil(lambda: qapp.topLevelWidgets() == [])
|
qtbot.waitUntil(lambda: qapp.topLevelWidgets() == [])
|
||||||
except QtBotTimeoutError as exc:
|
except QtBotTimeoutError as exc:
|
||||||
|
@ -2,6 +2,7 @@ from unittest import mock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from qtpy.QtCore import Qt
|
from qtpy.QtCore import Qt
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
from bec_widgets.utils.colors import set_theme
|
from bec_widgets.utils.colors import set_theme
|
||||||
from bec_widgets.widgets.dark_mode_button.dark_mode_button import DarkModeButton
|
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()
|
dark_mode_button.toggle_dark_mode()
|
||||||
mocked_set_theme.assert_called_with("light")
|
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"
|
||||||
|
@ -61,7 +61,7 @@ def test_vscode_cleanup(qtbot, patched_vscode_process):
|
|||||||
vscode_patched, mock_killpg = patched_vscode_process
|
vscode_patched, mock_killpg = patched_vscode_process
|
||||||
vscode_patched.process.pid = 123
|
vscode_patched.process.pid = 123
|
||||||
vscode_patched.process.poll.return_value = None
|
vscode_patched.process.poll.return_value = None
|
||||||
vscode_patched.cleanup()
|
vscode_patched.cleanup_vscode()
|
||||||
mock_killpg.assert_called_once_with(123, 15)
|
mock_killpg.assert_called_once_with(123, 15)
|
||||||
vscode_patched.process.wait.assert_called_once()
|
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, mock_killpg = patched_vscode_process
|
||||||
vscode_patched.process.pid = 123
|
vscode_patched.process.pid = 123
|
||||||
vscode_patched.process.poll.return_value = 0
|
vscode_patched.process.poll.return_value = 0
|
||||||
vscode_patched.cleanup()
|
vscode_patched.cleanup_vscode()
|
||||||
mock_killpg.assert_not_called()
|
mock_killpg.assert_not_called()
|
||||||
vscode_patched.process.wait.assert_not_called()
|
vscode_patched.process.wait.assert_not_called()
|
||||||
|
@ -2,8 +2,11 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
import pytest
|
import pytest
|
||||||
|
from qtpy.QtGui import QColor
|
||||||
|
from qtpy.QtWidgets import QApplication
|
||||||
|
|
||||||
from bec_widgets.qt_utils.settings_dialog import SettingsDialog
|
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.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_popups.curve_dialog.curve_dialog import CurveSettings
|
||||||
from bec_widgets.widgets.waveform.waveform_widget import BECWaveformWidget
|
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"]["y_scale"] == "linear"
|
||||||
assert waveform_widget._config_dict["axis"]["x_lim"] == (5, 15)
|
assert waveform_widget._config_dict["axis"]["x_lim"] == (5, 15)
|
||||||
assert waveform_widget._config_dict["axis"]["y_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
|
||||||
|
Reference in New Issue
Block a user