"""
PyMoDAQ Theming Demo — Status Color Palette + Material Icon Toolbar
=====================================================================
Run this file directly::
python theming_demo.py
Switching theme at runtime
--------------------------
Switching theme (combobox, or the Play button to auto-cycle through every
``qt_themes`` theme) calls ``pymodaq_gui.qt_utils.apply_theme(name)``, which
sets the palette and style, keeps PyMoDAQ's dark-theme checkbox rule and
emits ``theme_signaller.theme_changed``. Then:
- the toolbar, built *once* with an ``ActionManager``, re-colors itself.
``MaterialIcon`` (pymodaq_gui.resources.material_icons) bakes the palette
color into a pixmap at construction (see ``SVGIcon._init_colors``), so each
action keeps what its icon is made of and rebuilds it with the current theme
colors on ``theme_changed`` (``ActionManager.refresh_icons()`` does it on
demand). Colors given by name (``ThemeColor.GREEN``, ``'green'``,
``StatusPalette.role('running')``) follow the new theme; literal colors
(``'#3f8f7f'``) are kept, as is any color of an action created with
``follow_theme=False``;
- the status-color reference table and the live ``MultistateLED`` demo
(community-proposed six-state convention, see ``utils/status_palette.py``)
are plain widgets with colors computed once, so they are rebuilt.
The toolbar also shows two independent, non-animated uses of icon color:
- a *momentary* action (Refresh / Save / Stop) tinted from the same
six-state ``StatusPalette`` convention as the LEDs above it;
- a *checkable* action (Pause / Grid / Zoom) whose icon carries two distinct
pixmaps, one per ``QIcon.State`` (On/Off), from ``icon_checked_color``.
Qt swaps between them on its own from ``QAction.isChecked()``; click a
toggle button to see it.
"""
import sys
import qt_themes
from qtpy import QtCore, QtGui, QtWidgets
from pymodaq_gui.managers.action_manager import ActionManager
from pymodaq_gui.qt_utils import apply_theme
from pymodaq_gui.utils.widgets.multistate_led import MultistateLED
from pymodaq_gui.utils.status_palette import StatusPalette, _DEFINITIONS
# ── Available themes ────────────────────────────────────────────────────────
try:
_THEME_NAMES: list[str] = sorted(qt_themes.get_themes().keys())
except AttributeError:
_THEME_NAMES = [
'atom_one', 'blender',
'catppuccin_frappe', 'catppuccin_latte',
'catppuccin_macchiato', 'catppuccin_mocha',
'dracula', 'github_dark', 'github_light',
'modern_dark', 'modern_light', 'monokai',
'nord', 'one_dark_two',
]
# ── Status-palette reference data ───────────────────────────────────────────
# Human-readable descriptions for each state
_DESCRIPTIONS = {
'off': 'Module absent, not initialized, or hardware not yet connected.',
'idle': 'Initialized and ready — waiting for a user command or trigger.',
'running': 'A command is in flight: moving, acquiring, or processing data.',
'warning': 'Non-fatal issue detected — still functional, attention advised.',
'error': 'An operation failed. Module may still recover, attention advised.',
'critical': 'Unrecoverable fault — timeout, hardware error, or fatal exception.',
}
# Logging-level analogy for the reference table
_LOG_LEVEL = {
'off': '—',
'idle': '—',
'running': '—',
'warning': 'WARNING',
'error': 'ERROR',
'critical': 'CRITICAL',
}
# ── Icon toolbar data ────────────────────────────────────────────────────────
# (icon name, label, tooltip, status_key, checkable, checked_color, start_checked)
#
# icon names all confirmed present in resources/icons.toml.
#
# Two independent, non-animated uses of color:
# - status_key: a *momentary* action tinted from the six-state StatusPalette
# convention -- refresh/save/stop always show that color, checked or not.
# - checkable + checked_color: a *toggle* action whose icon literally has two
# pixmaps, one per QIcon.State (On/Off) -- exactly what action_manager.py's
# own QAction does for icon_checked/icon_unchecked. Qt swaps between them
# natively based on QAction.isChecked(); no timer, no manual repaint.
# Theme colors follow theme changes; _ACCENT_ON, a literal, is kept.
_ACCENT_ON = '#3f8f7f' # generic "toggled on" tint for plain UI toggles (not a device state)
_STATUS_NAMES = {'off', 'idle', 'running', 'warning', 'error', 'critical'}
def _resolve_checked_color(spec: str) -> str:
"""A checked_color entry is either a StatusPalette state name or a literal hex."""
if spec in _STATUS_NAMES:
return StatusPalette.role(spec) # a theme colour name: follows the theme
return spec
_TOOLBAR_ACTIONS = [
# icon, label, tooltip, status_key, checkable, checked_color, start_checked
('home', 'Home', 'Go to the dashboard home', None, False, None, False),
('search', 'Search', 'Search modules', None, False, None, False),
('tune', 'Tune', 'Open actuator settings', None, False, None, False),
('settings', 'Settings','Open preferences', None, False, None, False),
('refresh', 'Refresh', 'Refresh the view — acquisition in flight', 'running', False, None, False),
('save', 'Save', 'Save the current state — ready', 'idle', False, None, False),
('pause_circle', 'Pause', 'Toggle pause — checked means paused', None, True, 'warning', False),
('stop', 'Stop', 'Stop the current module — critical fault', 'critical', False, None, False),
('grid_on', 'Grid', 'Toggle the grid overlay', None, True, _ACCENT_ON, True),
('zoom_in', 'Zoom', 'Toggle zoom lock', None, True, _ACCENT_ON, False),
('folder_open', 'Open', 'Open a file', None, False, None, False),
]
[docs]
class ThemingDemo(QtWidgets.QWidget):
"""Standalone demo: status-color LEDs + a MaterialIcon toolbar, both retheme correctly."""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle('PyMoDAQ — Theming Demo (status colors + icons)')
self._content_widget = None
self._build_skeleton()
self._build_toolbar()
self._rebuild_status_widgets()
# ── Fixed structure (built once) ────────────────────────────────────────
def _build_skeleton(self):
self._root = QtWidgets.QVBoxLayout(self)
self._root.setSpacing(12)
self._root.setContentsMargins(20, 20, 20, 20)
# Title
title = QtWidgets.QLabel('PyMoDAQ — Theming Demo')
font = title.font()
font.setPointSize(font.pointSize() + 3)
font.setBold(True)
title.setFont(font)
title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self._root.addWidget(title)
subtitle = QtWidgets.QLabel(
'A shared six-state status vocabulary for LEDs, icons, and status bars,\n'
'plus a MaterialIcon toolbar built once with an ActionManager: its icons re-color\n'
'themselves on every theme change. Use the combobox below to switch themes live.'
)
subtitle.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
subtitle.setWordWrap(True)
self._root.addWidget(subtitle)
# Theme selector row
theme_row = QtWidgets.QHBoxLayout()
theme_row.addWidget(QtWidgets.QLabel('<b>Theme:</b>'))
self._theme_combo = QtWidgets.QComboBox()
self._theme_combo.addItems(_THEME_NAMES)
self._theme_combo.setMinimumWidth(180)
# Pre-select the currently active theme
try:
from pymodaq_gui import config
current_theme = config('gui', 'style', 'theme')[0]
idx = self._theme_combo.findText(current_theme)
if idx >= 0:
self._theme_combo.setCurrentIndex(idx)
except Exception:
pass
self._theme_combo.currentTextChanged.connect(self._on_theme_changed)
theme_row.addWidget(self._theme_combo)
self._play_button = QtWidgets.QPushButton('▶ Play')
self._play_button.setCheckable(True)
self._play_button.setToolTip('Cycle through all themes automatically')
self._play_button.toggled.connect(self._on_play_toggled)
theme_row.addWidget(self._play_button)
theme_row.addWidget(QtWidgets.QLabel('every'))
self._interval_spin = QtWidgets.QSpinBox()
self._interval_spin.setRange(200, 10000)
self._interval_spin.setSingleStep(100)
self._interval_spin.setValue(1200)
self._interval_spin.setSuffix(' ms')
self._interval_spin.valueChanged.connect(self._on_interval_changed)
theme_row.addWidget(self._interval_spin)
theme_row.addStretch()
self._root.addLayout(theme_row)
self._cycle_timer = QtCore.QTimer(self)
self._cycle_timer.setInterval(self._interval_spin.value())
self._cycle_timer.timeout.connect(self._cycle_to_next_theme)
self._root.addWidget(_hline())
self._toolbar_layout = QtWidgets.QVBoxLayout()
self._root.addLayout(self._toolbar_layout)
# State table + LED demo + snippet easily exceed a
# reasonable fixed window height once the table rows are sized
# correctly (see _rebuild_status_widgets); scroll rather than let anything
# after the table get squeezed into whatever space is left.
self._scroll_area = QtWidgets.QScrollArea()
self._scroll_area.setWidgetResizable(True)
self._scroll_area.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
self._root.addWidget(self._scroll_area, 1)
def _build_toolbar(self):
"""Material icon toolbar, built once: its actions re-color themselves on theme change."""
toolbar_box = QtWidgets.QGroupBox(
'Material icon toolbar — status-tinted + checkable actions'
)
toolbar_layout = QtWidgets.QVBoxLayout(toolbar_box)
toolbar = QtWidgets.QToolBar()
toolbar.setIconSize(QtCore.QSize(28, 28))
toolbar.setToolButtonStyle(QtCore.Qt.ToolButtonStyle.ToolButtonTextUnderIcon)
self._action_manager = ActionManager(toolbar=toolbar)
for (icon_name, label, tooltip, status_key, checkable,
checked_color, start_checked) in _TOOLBAR_ACTIONS:
# Theme colours by name (StatusPalette.role -> ThemeColor): they follow the theme
self._action_manager.add_action(
icon_name, label, icon_name, tip=tooltip,
checkable=checkable, checked=start_checked,
icon_color=StatusPalette.role(status_key) if status_key is not None else None,
icon_checked_color=_resolve_checked_color(checked_color) if checkable else None,
)
toolbar_layout.addWidget(toolbar)
self._toolbar_layout.addWidget(toolbar_box)
# ── Color-dependent widgets (rebuilt on every theme change) ─────────────
def _rebuild_status_widgets(self):
"""Tear down and rebuild the status table and LED demo, whose colors are computed once."""
# QScrollArea.setWidget() below takes ownership of the new widget and
# deletes whatever widget it previously held -- no manual teardown needed.
self._content_widget = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(self._content_widget)
layout.setSpacing(12)
layout.setContentsMargins(0, 0, 0, 0)
# ── State reference table ───────────────────────────────────────
# Built from QHBoxLayout rows stacked in a QVBoxLayout, not a
# QGridLayout: QGridLayout does not reliably size a row to a
# word-wrapped QLabel's real (multi-line) height, which made
# longer descriptions overlap the row below. A column of QHBoxLayout
# rows does not have that limitation.
col_widths = [28, 90, 90, 150] # LED, State, log level, Theme attribute
header_row = QtWidgets.QHBoxLayout()
header_row.setSpacing(16)
for header, width in zip(['', 'State', 'log level', 'Theme attribute'], col_widths):
lbl = QtWidgets.QLabel(f'<b>{header}</b>')
lbl.setFixedWidth(width)
header_row.addWidget(lbl)
header_row.addWidget(QtWidgets.QLabel('<b>Description</b>'), 1)
layout.addLayout(header_row)
states = StatusPalette.as_states()
for (name, color), (_, attr, _) in zip(states, _DEFINITIONS):
row = QtWidgets.QHBoxLayout()
row.setSpacing(16)
# LED fixed to its own state for visual reference
led = MultistateLED(states=[(name, color)], size=24)
led.set_state(name)
led.setFixedWidth(col_widths[0])
row.addWidget(led)
# State name coloured to match the LED
name_lbl = QtWidgets.QLabel(f'<b>{name}</b>')
name_lbl.setStyleSheet(
f'color: {color.name()}; font-family: monospace; font-size: 13px;'
)
name_lbl.setFixedWidth(col_widths[1])
row.addWidget(name_lbl)
# Logging analogy
log_lbl = QtWidgets.QLabel(_LOG_LEVEL.get(name, '—'))
log_lbl.setStyleSheet('font-size: 11px; color: gray;')
log_lbl.setFixedWidth(col_widths[2])
row.addWidget(log_lbl)
# Theme attribute
attr_lbl = QtWidgets.QLabel(f'theme.<i>{attr}</i>')
attr_lbl.setStyleSheet('color: gray; font-size: 11px;')
attr_lbl.setFixedWidth(col_widths[3])
row.addWidget(attr_lbl)
# Description -- wraps onto 2 lines for the longer entries; the
# row's own height follows it correctly since this is a linear
# (not grid) layout.
desc_lbl = QtWidgets.QLabel(_DESCRIPTIONS[name])
desc_lbl.setWordWrap(True)
row.addWidget(desc_lbl, 1)
layout.addLayout(row)
layout.addWidget(_hline())
# ── Live LED demo ───────────────────────────────────────────────
demo_box = QtWidgets.QGroupBox('Live demo — click the LED to cycle through states')
demo_layout = QtWidgets.QHBoxLayout(demo_box)
demo_layout.setSpacing(12)
demo_led = MultistateLED(
states=StatusPalette.as_states(),
readonly=False,
clickable_cycle=True,
size=32,
)
init_name = demo_led.get_state()
init_color = StatusPalette.color(init_name)
demo_state_lbl = QtWidgets.QLabel(f'<b>{init_name}</b>')
demo_state_lbl.setStyleSheet(f'color: {init_color.name()};')
demo_state_lbl.setMinimumWidth(80)
demo_desc_lbl = QtWidgets.QLabel(_DESCRIPTIONS[init_name])
demo_desc_lbl.setWordWrap(True)
def _on_state_change(state_name: str):
color = StatusPalette.color(state_name)
demo_state_lbl.setText(f'<b>{state_name}</b>')
demo_state_lbl.setStyleSheet(f'color: {color.name()};')
demo_desc_lbl.setText(_DESCRIPTIONS[state_name])
demo_led.state_changed.connect(_on_state_change)
demo_layout.addWidget(demo_led)
demo_layout.addWidget(demo_state_lbl)
demo_layout.addWidget(demo_desc_lbl, stretch=1)
layout.addWidget(demo_box)
layout.addWidget(_hline())
# ── Usage snippet ───────────────────────────────────────────────
layout.addWidget(QtWidgets.QLabel('<b>Usage</b>'))
snippet = QtWidgets.QPlainTextEdit()
snippet.setReadOnly(True)
snippet.setMaximumHeight(200)
snippet.setFont(QtGui.QFont('monospace'))
snippet.setPlainText(
'from pymodaq_gui.utils.status_palette import StatusPalette\n'
'from pymodaq_gui.utils.widgets.multistate_led import MultistateLED\n'
'from pymodaq_gui.managers.action_manager import ActionManager\n'
'from pymodaq_gui.qt_utils import apply_theme\n\n'
'# LED, in a widget\n'
'led = MultistateLED(states=StatusPalette.as_states())\n'
"led.set_state('running')\n\n"
'# LED, in a parameter tree\n'
"params = [{'name': 'status', 'type': 'action_multistate_led',\n"
" 'value': 'off', 'states': StatusPalette.as_states()}]\n\n"
"# Action tinted with a status color, following theme changes\n"
"manager.add_action('refresh', 'Refresh', 'refresh',\n"
" icon_color=StatusPalette.role('running'))\n"
"apply_theme('nord') # icons follow, no rebuild needed"
)
layout.addWidget(snippet)
self._scroll_area.setWidget(self._content_widget)
self.setWindowTitle(
f'PyMoDAQ — Theming Demo (status colors + icons) — {self._theme_combo.currentText()}'
)
# ── Slots ─────────────────────────────────────────────────────────────
def _on_theme_changed(self, name: str):
if apply_theme(name) is None:
return
self._rebuild_status_widgets()
def _on_play_toggled(self, checked: bool):
if checked:
self._play_button.setText('■ Stop')
self._cycle_timer.start()
else:
self._play_button.setText('▶ Play')
self._cycle_timer.stop()
def _on_interval_changed(self, value: int):
self._cycle_timer.setInterval(value)
def _cycle_to_next_theme(self):
count = self._theme_combo.count()
if count == 0:
return
next_index = (self._theme_combo.currentIndex() + 1) % count
self._theme_combo.setCurrentIndex(next_index)
[docs]
def closeEvent(self, event):
self._cycle_timer.stop()
super().closeEvent(event)
def _hline() -> QtWidgets.QFrame:
line = QtWidgets.QFrame()
line.setFrameShape(QtWidgets.QFrame.Shape.HLine)
line.setFrameShadow(QtWidgets.QFrame.Shadow.Sunken)
return line
[docs]
def main():
from pymodaq_gui.qt_utils import mkQApp
app = mkQApp('ThemingDemo')
w = ThemingDemo()
w.resize(880, 820)
w.show()
sys.exit(app.exec())
if __name__ == '__main__':
main()