Source code for pymodaq_gui.examples.theming_demo

"""
PyMoDAQ Theming Demo — Status Color Palette + Material Icon Toolbar
=====================================================================

Run this file directly::

    python theming_demo.py

One theme control drives two different rebuild strategies
-----------------------------------------------------------
Switching theme (combobox, or the Play button to auto-cycle through every
``qt_themes`` theme) calls a single ``_refresh_colors()`` that rebuilds
*everything* color-dependent in this window from scratch:

- the status-color reference table and the live ``MultistateLED`` demo
  (community-proposed six-state convention, see ``utils/status_palette.py``);
- a ``QToolBar`` of ``MaterialIcon`` actions.

Rebuilding from scratch is required for the icons specifically:
``MaterialIcon`` (pymodaq_gui.resources.material_icons) bakes the active
``QApplication.palette()`` color into a rasterized QPixmap exactly once, at
construction time (see ``SVGIcon._init_colors``). There is no
``paletteChanged`` hook, so simply calling ``qt_themes.set_theme(...)`` on an
already-built toolbar leaves every icon in its old color — only icons built
*after* the switch pick up the new palette. That is the same change a real
DAQ_Move / DAQ_Viewer toolbar would need to retheme live.

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 ``MaterialIcon`` carries
  two distinct pixmaps, one per ``QIcon.State`` (On/Off) — the same
  mechanism ``action_manager.QAction`` uses for its own
  ``icon_checked`` / ``icon_unchecked`` pair. Qt swaps between them on its
  own from ``QAction.isChecked()``; click a toggle button to see it.

  Note for anyone reusing this pattern: ``QIcon`` is a copy-on-write value
  type, so ``QAction(icon, ...)`` takes its own copy at construction time.
  Both icon states must be set on the icon *before* it is handed to the
  QAction -- mutating it afterwards detaches and lands only on the local
  reference, never reaching the action.
"""

import sys

import qt_themes
from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtWidgets import QAction

from pymodaq_gui.resources.material_icons import MaterialIcon
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.
_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) -> QtGui.QColor:
    """A checked_color entry is either a StatusPalette state name or a literal hex."""
    if spec in _STATUS_NAMES:
        return StatusPalette.color(spec)
    return QtGui.QColor(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._toolbar = None self._actions = [] self._build_skeleton() self._refresh_colors() # ── 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 rebuilt from scratch on every theme change --\n' 'colors are baked into a pixmap once, at construction, so repainting them\n' 'in place is not an option. 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()) # State table + LED demo + toolbar + snippet easily exceed a # reasonable fixed window height once the table rows are sized # correctly (see _refresh_colors); 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) # ── Color-dependent content (rebuilt on every theme change) ───────────── def _refresh_colors(self): """Tear down and rebuild every color-dependent widget: LEDs and icons alike.""" # 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()) # ── Material icon toolbar ─────────────────────────────────────── toolbar_box = QtWidgets.QGroupBox( 'Material icon toolbar — status-tinted + checkable actions' ) toolbar_layout = QtWidgets.QVBoxLayout(toolbar_box) self._toolbar = QtWidgets.QToolBar() self._toolbar.setIconSize(QtCore.QSize(28, 28)) self._toolbar.setToolButtonStyle(QtCore.Qt.ToolButtonStyle.ToolButtonTextUnderIcon) self._actions = [] for (icon_name, label, tooltip, status_key, checkable, checked_color, start_checked) in _TOOLBAR_ACTIONS: # Only the 'rounded' style is bundled with pymodaq_gui (see resources/icons.toml) icon = MaterialIcon(icon_name, style=MaterialIcon.ROUNDED) if status_key is not None: # Re-resolved against the *current* theme on every rebuild, same # as StatusPalette.as_states() does for the LED table above. icon.set_color(StatusPalette.color(status_key)) if checkable: # Both states must be set on `icon` BEFORE it is handed to # QAction() below -- see the module docstring's note on # QIcon's copy-on-write semantics. icon.set_color(_resolve_checked_color(checked_color), state=QtGui.QIcon.State.On) action = QAction(icon, label, self._toolbar) action.setToolTip(tooltip) if checkable: action.setCheckable(True) action.setChecked(start_checked) self._toolbar.addAction(action) self._actions.append(action) toolbar_layout.addWidget(self._toolbar) layout.addWidget(toolbar_box) layout.addWidget(_hline()) # ── Usage snippet ─────────────────────────────────────────────── layout.addWidget(QtWidgets.QLabel('<b>Usage</b>')) snippet = QtWidgets.QPlainTextEdit() snippet.setReadOnly(True) snippet.setMaximumHeight(140) 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.resources.material_icons import MaterialIcon\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" "# Icon tinted with a status color\n" "icon = MaterialIcon('refresh', style=MaterialIcon.ROUNDED)\n" "icon.set_color(StatusPalette.color('running'))" ) 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): try: qt_themes.set_theme(name) except Exception: pass self._refresh_colors() 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()