diff --git a/bec_widgets/utils/bec_widget.py b/bec_widgets/utils/bec_widget.py index 7235f666..39be8219 100644 --- a/bec_widgets/utils/bec_widget.py +++ b/bec_widgets/utils/bec_widget.py @@ -1,3 +1,4 @@ +from qtpy.QtCore import Slot from qtpy.QtWidgets import QApplication, QWidget 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. 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): raise RuntimeError(f"{repr(self)} is not a subclass of QWidget") super().__init__(client, config, gui_id) @@ -19,7 +26,36 @@ 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) + + @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): """Cleanup the widget.""" diff --git a/bec_widgets/utils/colors.py b/bec_widgets/utils/colors.py index de98ccba..a94e100e 100644 --- a/bec_widgets/utils/colors.py +++ b/bec_widgets/utils/colors.py @@ -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,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. """ 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() - app.theme_signal.theme_updated.emit(app.theme["theme"]) - 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"]): @@ -55,21 +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") + 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) diff --git a/bec_widgets/widgets/dark_mode_button/dark_mode_button.py b/bec_widgets/widgets/dark_mode_button/dark_mode_button.py index ca9cead6..40601248 100644 --- a/bec_widgets/widgets/dark_mode_button/dark_mode_button.py +++ b/bec_widgets/widgets/dark_mode_button/dark_mode_button.py @@ -18,7 +18,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 +39,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. diff --git a/bec_widgets/widgets/figure/plots/plot_base.py b/bec_widgets/widgets/figure/plots/plot_base.py index 7410b366..55d43284 100644 --- a/bec_widgets/widgets/figure/plots/plot_base.py +++ b/bec_widgets/widgets/figure/plots/plot_base.py @@ -99,30 +99,32 @@ class BECPlotBase(BECConnector, pg.GraphicsLayout): self.add_legend() self.crosshair = None - self.connect_to_theme_change() - self.apply_theme() + self._connect_to_theme_change() - def connect_to_theme_change(self): + 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.apply_theme) + qapp.theme_signal.theme_updated.connect(self._update_theme) @Slot(str) - @Slot() - def apply_theme(self, theme: str | None = None): - """ - Apply the theme to the plot widget. - - Args: - theme(str, optional): The theme to be applied. - """ + 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()) diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index 2ba47d43..e91b90f6 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -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: diff --git a/tests/unit_tests/test_dark_mode_button.py b/tests/unit_tests/test_dark_mode_button.py index 36f0f1ac..6114d648 100644 --- a/tests/unit_tests/test_dark_mode_button.py +++ b/tests/unit_tests/test_dark_mode_button.py @@ -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" diff --git a/tests/unit_tests/test_vscode_widget.py b/tests/unit_tests/test_vscode_widget.py index c8d07e1b..bf81a02c 100644 --- a/tests/unit_tests/test_vscode_widget.py +++ b/tests/unit_tests/test_vscode_widget.py @@ -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() diff --git a/tests/unit_tests/test_waveform_widget.py b/tests/unit_tests/test_waveform_widget.py index 19a71849..abe31e8a 100644 --- a/tests/unit_tests/test_waveform_widget.py +++ b/tests/unit_tests/test_waveform_widget.py @@ -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