0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-14 11:41:49 +02:00

feat(theme): added theme handler to bec widget base class; added tests

This commit is contained in:
2024-08-31 14:32:10 +02:00
parent 08c3d7d175
commit 7fb938a850
8 changed files with 143 additions and 40 deletions

View File

@ -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."""

View File

@ -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)

View File

@ -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.

View File

@ -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())

View File

@ -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:

View File

@ -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"

View File

@ -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()

View File

@ -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