Source code for pymodaq_gui.utils.widgets.multistate_led

from qtpy import QtCore, QtGui, QtWidgets
from qtpy.QtCore import Signal
from pymodaq_utils.enums import StrEnum
from pymodaq_gui.utils.widgets.painter_utils import draw_shape, make_brush, desaturate, SHAPES, GRADIENTS


[docs] class LedState(StrEnum): """Default two-state set for :class:`MultistateLED` — visually matches QLED. Members behave as plain ``str`` (e.g. ``LedState.TRUE == 'true'``), so existing code passing ``'true'`` / ``'false'`` string literals keeps working unchanged. """ FALSE = 'False' TRUE = 'True'
# Default two-state set — visually matches QLED DEFAULT_STATES = [ (LedState.FALSE, '#c80000'), (LedState.TRUE, '#00b400'), ]
[docs] class MultistateLED(QtWidgets.QWidget): """A painted LED indicator that can represent any number of named states. Each state is a (name, color) pair supplied at construction time. The widget emits ``state_changed`` with the state name whenever the active state changes. Parameters ---------- parent : QWidget, optional states : list of (str, str | QColor), optional Ordered ``[(name, color), ...]`` pairs. *name* can be a plain string or a :class:`~pymodaq_utils.enums.StrEnum` member (e.g. :class:`LedState` or :class:`~pymodaq_gui.utils.status_palette.Status`) — both compare equal to their string value, so mixing the two is safe. *color* can be a hex string (``'#rrggbb'``), an SVG color name, or a ``QColor`` instance (e.g. from :class:`~pymodaq_gui.utils.status_palette.StatusPalette`). Defaults to :data:`DEFAULT_STATES` (a two-state red/green set keyed on :class:`LedState`). readonly : bool When ``True`` (default) clicking has no effect. Sets ``self.clickable`` to ``not readonly`` — assign ``w.clickable`` directly to change at runtime (same pattern as :class:`~pymodaq_gui.utils.widgets.qled.QLED`). clickable_cycle : bool When ``True`` (default) each click advances to the next state in order, wrapping around. When ``False``, clicking only toggles between the first and last state. size : int Diameter / side length of the shape in pixels (default 20). shape : str Visual shape: one of ``'circle'``, ``'square'``, ``'triangle'``, ``'diamond'``, ``'rectangle'``. Defaults to ``'circle'``. gradient : str Fill style passed to :func:`~pymodaq_gui.utils.widgets.painter_utils.make_brush`. One of ``'flat'`` (default), ``'radial'``, ``'linear'``, ``'glow'``. """ state_changed = Signal(str) # emits state name def __init__( self, parent=None, states=None, readonly: bool = True, clickable_cycle: bool = True, size: int = 20, shape: str = 'circle', gradient: str = 'radial', ): super().__init__(parent) if shape not in SHAPES: raise ValueError(f"Unknown shape {shape!r}. Valid shapes: {SHAPES}") if gradient not in GRADIENTS: raise ValueError(f"Unknown gradient {gradient!r}. Valid styles: {GRADIENTS}") self._states: list[tuple[str, QtGui.QColor]] = [] self._diameter = size self.clickable = not readonly # public — set directly to change at runtime self._cycle = clickable_cycle self._gradient = gradient self._shape = shape states = states if states is not None else DEFAULT_STATES for name, color in states: self._states.append( (name, color if isinstance(color, QtGui.QColor) else QtGui.QColor(color)) ) if not self._states: raise ValueError("states must contain at least one entry") self._index = 0 # index into self._states self.setFixedSize(self._diameter, self._diameter) self._update_cursor() # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------
[docs] def get_state(self) -> str: """Return the current state name.""" return self._states[self._index][0]
[docs] def set_state(self, name: str): """Set the active state by name. Emits ``state_changed`` if changed.""" for ind, (state, _) in enumerate(self._states): if state.lower() == name.lower(): if ind != self._index: self._index = ind self.update() self.state_changed.emit(state) return raise ValueError(f"Unknown state {name!r}. Valid states: {[state for state, _ in self._states]}")
[docs] def state_names(self) -> list[str]: """Return the ordered list of state names.""" return [n for n, _ in self._states]
[docs] def set_states(self, states: list): """Replace the full state list. Current state is reset to index 0.""" if not states: raise ValueError("states must contain at least one entry") self._states = [ (name, color if isinstance(color, QtGui.QColor) else QtGui.QColor(color)) for name, color in states ] self._index = 0 self.update()
[docs] def set_gradient(self, gradient: str): if gradient not in GRADIENTS: raise ValueError(f"Unknown gradient {gradient!r}. Valid styles: {GRADIENTS}") self._gradient = gradient self.update()
[docs] def set_shape(self, shape: str): if shape not in SHAPES: raise ValueError(f"Unknown shape {shape!r}. Valid shapes: {SHAPES}") self._shape = shape self.update()
[docs] def set_size(self, size: int): self._diameter = size self.setFixedSize(size, size) self.update()
[docs] def cycle(self): """Advance to the next state programmatically (ignores ``clickable``).""" n = len(self._states) new_index = (self._index + 1) % n if self._cycle else (n - 1 if self._index != n - 1 else 0) self._index = new_index self.update() self.state_changed.emit(self._states[self._index][0])
def _update_cursor(self): if self.clickable: self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) else: self.unsetCursor() # ------------------------------------------------------------------ # Painting # ------------------------------------------------------------------
[docs] def paintEvent(self, event): _, color = self._states[self._index] if not self.isEnabled(): color = desaturate(color) border_width = max(1, self._diameter // 12) margin = border_width d = min(self.width(), self.height()) - 2 * margin x = (self.width() - d) / 2 y = (self.height() - d) / 2 rect = QtCore.QRectF(x, y, d, d) painter = QtGui.QPainter(self) painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing) painter.setBrush(make_brush(color, rect, gradient=self._gradient)) painter.setPen(QtGui.QPen(color.darker(160), 1)) draw_shape(painter, self._shape, rect) painter.end()
[docs] def sizeHint(self): return QtCore.QSize(self._diameter, self._diameter)
[docs] def changeEvent(self, event): if event.type() == QtCore.QEvent.Type.EnabledChange: self.update() super().changeEvent(event)
# ------------------------------------------------------------------ # Click handling # ------------------------------------------------------------------
[docs] def mouseReleaseEvent(self, event): if self.clickable and self.rect().contains(event.pos()): n = len(self._states) if self._cycle: new_index = (self._index + 1) % n else: new_index = n - 1 if self._index != n - 1 else 0 self._index = new_index self.update() self.state_changed.emit(self._states[self._index][0]) event.accept()
[docs] def main(): app = QtWidgets.QApplication([]) from pymodaq_gui.utils.status_palette import StatusPalette w = MultistateLED(states=StatusPalette.as_states(), readonly=False) timer = QtCore.QTimer(w, interval=1000) timer.timeout.connect(w.cycle) timer.start() w.resize(640, 480) w.show() return app.exec_()
if __name__ == '__main__': main()