diff --git a/src/hextools/gui/__main__.py b/src/hextools/gui/__main__.py index c7557ee..1908b17 100644 --- a/src/hextools/gui/__main__.py +++ b/src/hextools/gui/__main__.py @@ -70,7 +70,8 @@ QtPlanStatus, ) from hextools.gui.shutter_status import QtShutterStatus -from hextools.gui._theme import apply_bnl_theme +from hextools.gui._theme import apply_bnl_theme, saved_theme +from hextools.gui.theme_switch import QtThemeSwitch from hextools.photon_delivery_system.dclm import change_beam_mode from hextools.tomography.alignment import tomo_alignment_scan from hextools.tomography.flyscans import tomo_1d_step_scan, tomo_2d_step_scan, tomo_flyscan @@ -486,6 +487,8 @@ def __init__(self, re_client: RunEngineClient | RunEngine, *, show: bool = True) self._status_bar.showMessage(self._re_state_text()) self._help = QLabel("") self._status_bar.addPermanentWidget(self._help) + self._dark_mode = QtThemeSwitch() + self._status_bar.addPermanentWidget(self._dark_mode) if isinstance(re_client, RunEngine): self._install_state_hook(re_client) else: @@ -629,7 +632,7 @@ def launch_local_viewer(): # IPython's Qt event loop hook runs after startup, so create the # QApplication now to build widgets safely. app = QApplication.instance() or QApplication([]) - apply_bnl_theme(app) + apply_bnl_theme(app, saved_theme()) re = IPython.get_ipython().user_ns.get("RE", None) if not isinstance(re, RunEngine): raise RuntimeError("RE not found in IPython user namespace or is not a RunEngine instance.") @@ -679,7 +682,7 @@ def main(): if args.queueserver_uri: with gui_qt("HEX Queue Monitor"): - apply_bnl_theme() + apply_bnl_theme(theme=saved_theme()) re_client = RunEngineClient(http_server_uri = args.queueserver_uri) QtDataAcquisitionWindow(re_client=re_client) else: diff --git a/src/hextools/gui/_theme.py b/src/hextools/gui/_theme.py index d37ef06..5486d29 100644 --- a/src/hextools/gui/_theme.py +++ b/src/hextools/gui/_theme.py @@ -1,7 +1,7 @@ """BNL brand theming for the HEX GUI. -A dark theme built on the Brookhaven National Laboratory brand palette -(https://www.bnl.gov/brandcenter/palette.php). +Dark (default) and light themes built on the Brookhaven National Laboratory +brand palette (https://www.bnl.gov/brandcenter/palette.php). """ from __future__ import annotations @@ -60,7 +60,36 @@ "jade": JADE, } -STYLESHEET = f""" +# Surface/text colors that differ between themes; accents are shared. +THEMES = { + "dark": { + "BACKGROUND": BACKGROUND, + "SURFACE": SURFACE, + "ALT_ROW": ALT_ROW, + "BORDER": BORDER, + "BORDER_STRONG": BORDER_STRONG, + "TEXT": TEXT, + "TEXT_MUTED": TEXT_MUTED, + "ACCENT_TEXT": CERULEAN, + }, + "light": { + "BACKGROUND": "#F4F5F2", + "SURFACE": "#FFFFFF", + "ALT_ROW": "#EEF0EC", + "BORDER": "#D3D6D2", + "BORDER_STRONG": LIGHT_GRAY, + "TEXT": "#1E2227", + "TEXT_MUTED": GRAY, + # Cerulean text is too low-contrast on white. + "ACCENT_TEXT": TEAL, + }, +} +DEFAULT_THEME = "dark" +_SETTINGS_KEY = "ui/theme" +_current_theme = DEFAULT_THEME + +# A str.format template: literal braces are doubled. +_TEMPLATE = """ QWidget {{ background-color: {BACKGROUND}; color: {TEXT}; @@ -88,7 +117,7 @@ left: 12px; top: 8px; padding: 0 4px; - color: {CERULEAN}; + color: {ACCENT_TEXT}; font-weight: 600; }} @@ -170,13 +199,13 @@ }} QTabBar::tab:selected {{ background: {SURFACE}; - color: {CERULEAN}; + color: {ACCENT_TEXT}; font-weight: 600; border-top: 3px solid {LIME}; padding-top: 1px; margin-bottom: -1px; }} -QTabBar::tab:hover:!selected {{ color: {CERULEAN}; background: {ALT_ROW}; }} +QTabBar::tab:hover:!selected {{ color: {ACCENT_TEXT}; background: {ALT_ROW}; }} /* Main viewer uses a vertical (West) tab bar. Scope with '>' so the nested plan-editor (North) tab bar does not inherit these rules. */ @@ -280,14 +309,57 @@ QSplitter::handle:vertical {{ height: 4px; }} QStatusBar {{ background: {TEAL}; color: {ON_ACCENT}; }} QStatusBar::item {{ border: none; }} +QStatusBar QCheckBox {{ color: {ON_ACCENT}; }} """ -def apply_bnl_theme(app=None): +def build_stylesheet(theme: str = DEFAULT_THEME) -> str: + """Return the BNL-branded stylesheet for ``theme`` (``"dark"`` or ``"light"``).""" + if theme not in THEMES: + raise ValueError(f"Unknown theme {theme!r}; expected one of {sorted(THEMES)}") + return _TEMPLATE.format( + _ASSET_URL=_ASSET_URL, + TEAL=TEAL, + TEAL_DARK=TEAL_DARK, + TEAL_HOVER=TEAL_HOVER, + CERULEAN=CERULEAN, + LIME=LIME, + ON_ACCENT=ON_ACCENT, + **THEMES[theme], + ) + + +STYLESHEET = build_stylesheet(DEFAULT_THEME) + + +def current_theme() -> str: + """Return the name of the theme most recently applied.""" + return _current_theme + + +def saved_theme() -> str: + """Return the user's saved theme choice, or the default if none/invalid.""" + from qtpy.QtCore import QSettings + + value = QSettings("NSLS2", "hextools-gui").value(_SETTINGS_KEY, DEFAULT_THEME) + return value if value in THEMES else DEFAULT_THEME + + +def save_theme(theme: str) -> None: + """Remember ``theme`` as the user's choice for future sessions.""" + from qtpy.QtCore import QSettings + + QSettings("NSLS2", "hextools-gui").setValue(_SETTINGS_KEY, theme) + + +def apply_bnl_theme(app=None, theme: str = DEFAULT_THEME): """Apply the BNL-branded stylesheet to the given (or current) QApplication.""" + global _current_theme + stylesheet = build_stylesheet(theme) if app is None: from qtpy.QtWidgets import QApplication app = QApplication.instance() if app is not None: - app.setStyleSheet(STYLESHEET) + app.setStyleSheet(stylesheet) + _current_theme = theme diff --git a/src/hextools/gui/theme_switch.py b/src/hextools/gui/theme_switch.py new file mode 100644 index 0000000..383d2fc --- /dev/null +++ b/src/hextools/gui/theme_switch.py @@ -0,0 +1,47 @@ +"""A dark-mode switch that stays in step with the application-wide theme.""" + +from __future__ import annotations + +from qtpy.QtCore import QObject, Signal +from qtpy.QtWidgets import QApplication, QCheckBox + +from hextools.gui._theme import apply_bnl_theme, current_theme, save_theme + + +class _ThemeNotifier(QObject): + changed = Signal(str) + + +def _notifier() -> _ThemeNotifier: + # One per QApplication, so every window's switch hears every change. + app = QApplication.instance() + notifier = app.findChild(_ThemeNotifier) if app is not None else None + if notifier is None: + notifier = _ThemeNotifier(app) + return notifier + + +def set_theme(theme: str) -> None: + """Apply ``theme`` app-wide, remember it, and update every switch.""" + apply_bnl_theme(theme=theme) + save_theme(theme) + _notifier().changed.emit(theme) + + +class QtThemeSwitch(QCheckBox): + """Checkbox labelled "Dark mode" that sets the whole app's theme.""" + + def __init__(self, parent=None): + super().__init__("Dark mode", parent) + self.setToolTip("Switch between dark and light backgrounds") + self.setChecked(current_theme() == "dark") + self.toggled.connect(self._on_toggled) + _notifier().changed.connect(self._follow) + + def _on_toggled(self, checked: bool): + set_theme("dark" if checked else "light") + + def _follow(self, theme: str): + self.blockSignals(True) + self.setChecked(theme == "dark") + self.blockSignals(False) diff --git a/tests/test_gui_theme.py b/tests/test_gui_theme.py new file mode 100644 index 0000000..d8ccd7c --- /dev/null +++ b/tests/test_gui_theme.py @@ -0,0 +1,78 @@ +import pytest + +from hextools.gui import _theme # noqa: PLC2701 + + +@pytest.mark.parametrize("theme", sorted(_theme.THEMES)) +def test_build_stylesheet_fills_every_placeholder(theme): + stylesheet = _theme.build_stylesheet(theme) + assert _theme.THEMES[theme]["BACKGROUND"] in stylesheet + # Unfilled placeholders or unescaped braces would leave single braces behind. + assert "{{" not in stylesheet + assert "}}" not in stylesheet + + +def test_dark_is_default_and_themes_differ(): + assert _theme.DEFAULT_THEME == "dark" + assert _theme.STYLESHEET == _theme.build_stylesheet("dark") + assert _theme.build_stylesheet("dark") != _theme.build_stylesheet("light") + + +def test_unknown_theme_rejected(): + with pytest.raises(ValueError, match="Unknown theme"): + _theme.build_stylesheet("sepia") + + +@pytest.fixture +def qt_app(monkeypatch, tmp_path): + pytest.importorskip("qtpy") + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + from qtpy.QtCore import QSettings + from qtpy.QtWidgets import QApplication + + QSettings.setPath( + QSettings.Format.NativeFormat, QSettings.Scope.UserScope, str(tmp_path) + ) + QSettings.setPath( + QSettings.Format.IniFormat, QSettings.Scope.UserScope, str(tmp_path) + ) + app = QApplication.instance() or QApplication([]) + _theme.apply_bnl_theme(app, _theme.DEFAULT_THEME) + return app + + +def test_apply_and_remember_theme(qt_app): + app = qt_app + assert _theme.saved_theme() == "dark" + + _theme.apply_bnl_theme(app, "light") + _theme.save_theme("light") + assert app.styleSheet() == _theme.build_stylesheet("light") + assert _theme.current_theme() == "light" + assert _theme.saved_theme() == "light" + + _theme.apply_bnl_theme(app, "dark") + assert app.styleSheet() == _theme.STYLESHEET + assert _theme.current_theme() == "dark" + + +def test_every_switch_follows_a_theme_change(qt_app): + # The stylesheet is application-wide, but each window has its own switch. + # PR 91 review: a second window's switch kept showing the old theme. + from hextools.gui.theme_switch import QtThemeSwitch + + first, second = QtThemeSwitch(), QtThemeSwitch() + assert first.isChecked() and second.isChecked() + + first.setChecked(False) + assert _theme.current_theme() == "light" + assert qt_app.styleSheet() == _theme.build_stylesheet("light") + assert not second.isChecked() + + second.setChecked(True) + assert _theme.current_theme() == "dark" + assert first.isChecked() + + # A switch made after a change starts in step with it. + second.setChecked(False) + assert not QtThemeSwitch().isChecked()