Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/hextools/gui/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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:
Expand Down
88 changes: 80 additions & 8 deletions src/hextools/gui/_theme.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -88,7 +117,7 @@
left: 12px;
top: 8px;
padding: 0 4px;
color: {CERULEAN};
color: {ACCENT_TEXT};
font-weight: 600;
}}

Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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
47 changes: 47 additions & 0 deletions src/hextools/gui/theme_switch.py
Original file line number Diff line number Diff line change
@@ -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)
78 changes: 78 additions & 0 deletions tests/test_gui_theme.py
Original file line number Diff line number Diff line change
@@ -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()
Loading